Skip to content

Commit 0f40faa

Browse files
authored
fqcn[deep]: detect deep plugins (#3502)
1 parent d54b51c commit 0f40faa

File tree

8 files changed

+153
-2
lines changed

8 files changed

+153
-2
lines changed

.github/workflows/tox.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
WSLENV: FORCE_COLOR:PYTEST_REQPASS:TOXENV:GITHUB_STEP_SUMMARY
6060
# Number of expected test passes, safety measure for accidental skip of
6161
# tests. Update value if you add/remove tests.
62-
PYTEST_REQPASS: 802
62+
PYTEST_REQPASS: 804
6363
steps:
6464
- name: Activate WSL1
6565
if: "contains(matrix.shell, 'wsl')"
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""An ansible test module."""
2+
3+
4+
DOCUMENTATION = """
5+
module: mod_1
6+
author:
7+
- test
8+
short_description: This is a test module
9+
description:
10+
- This is a test module
11+
version_added: 1.0.0
12+
options:
13+
foo:
14+
description:
15+
- Dummy option I(foo)
16+
type: str
17+
bar:
18+
description:
19+
- Dummy option I(bar)
20+
default: candidate
21+
type: str
22+
choices:
23+
- candidate
24+
- running
25+
aliases:
26+
- bam
27+
notes:
28+
- This is a dummy module
29+
"""
30+
31+
EXAMPLES = """
32+
- name: test task-1
33+
company_name.coll_1.mod_1:
34+
foo: some value
35+
bar: candidate
36+
"""
37+
38+
RETURN = """
39+
baz:
40+
description: test return 1
41+
returned: success
42+
type: list
43+
sample: ['a','b']
44+
"""
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""An ansible test module."""
2+
3+
4+
DOCUMENTATION = """
5+
module: mod_2
6+
author:
7+
- test
8+
short_description: This is a test module
9+
description:
10+
- This is a test module
11+
version_added: 1.0.0
12+
options:
13+
foo:
14+
description:
15+
- Dummy option I(foo)
16+
type: str
17+
bar:
18+
description:
19+
- Dummy option I(bar)
20+
default: candidate
21+
type: str
22+
choices:
23+
- candidate
24+
- running
25+
aliases:
26+
- bam
27+
notes:
28+
- This is a dummy module
29+
"""
30+
31+
EXAMPLES = """
32+
- name: test task-1
33+
company_name.coll_1.mod_2:
34+
foo: some value
35+
bar: candidate
36+
"""
37+
38+
RETURN = """
39+
baz:
40+
description: test return 1
41+
returned: success
42+
type: list
43+
sample: ['a','b']
44+
"""

src/ansiblelint/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
DEFAULT_WARN_LIST = [
3535
"experimental",
3636
"jinja[spacing]", # warning until we resolve all reported false-positives
37+
"fqcn[deep]", # 2023-05-31 added
3738
]
3839

3940
DEFAULT_KINDS = [
@@ -74,6 +75,11 @@
7475
{"yaml": "**/*.{yaml,yml}"},
7576
{"yaml": "**/.*.{yaml,yml}"},
7677
{"sanity-ignore-file": "**/tests/sanity/ignore-*.txt"},
78+
# what are these doc_fragments? We also ignore module_utils for now
79+
{
80+
"plugin": "**/plugins/{action,become,cache,callback,connection,filter,inventory,lookup,modules,test}/**/*.py",
81+
},
82+
{"python": "**/*.py"},
7783
]
7884

7985
BASE_KINDS = [
@@ -93,6 +99,7 @@
9399
{"text/yaml": "**/{.ansible-lint,.yamllint}"},
94100
{"text/yaml": "**/*.{yaml,yml}"},
95101
{"text/yaml": "**/.*.{yaml,yml}"},
102+
{"text/python": "**/*.py"},
96103
]
97104

98105
PROFILES = yaml_from_file(Path(__file__).parent / "data" / "profiles.yml")

src/ansiblelint/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def main():
7272
"yaml", # generic yaml file, previously reported as unknown file type
7373
"ansible-lint-config",
7474
"sanity-ignore-file", # tests/sanity/ignore file
75+
"plugin",
7576
"", # unknown file type
7677
]
7778

src/ansiblelint/file_utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,8 +400,9 @@ def data(self) -> Any:
400400
self.state = append_skipped_rules(self.state, self)
401401
else:
402402
logging.debug(
403-
"data set to None for %s due to being of %s kind.",
403+
"data set to None for %s due to being '%s' (%s) kind.",
404404
self.path,
405+
self.kind,
405406
self.base_kind or "unknown",
406407
)
407408
self.state = States.UNKNOWN_DATA

src/ansiblelint/rules/fqcn.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ The `fqcn` rule has the following checks:
1212
- `fqcn[action-core]` - Checks for FQCNs from the `ansible.legacy` or
1313
`ansible.builtin` collection.
1414
- `fqcn[canonical]` - You should use canonical module name ... instead of ...
15+
- [`fqcn[deep]`](#deep-modules) - Checks for deep/nested plugins directory
16+
inside collections.
1517
- `fqcn[keyword]` - Avoid `collections` keyword by using FQCN for all plugins,
1618
modules, roles and playbooks.
1719

@@ -41,6 +43,17 @@ compatible with a very old version of Ansible, one that does not know how to
4143
resolve that name. If you find yourself in such a situation, feel free to add
4244
this rule to the ignored list.
4345

46+
## Deep modules
47+
48+
When writing modules, you should avoid nesting them in deep directories, even if
49+
Ansible allows you to do so. Since early 2023, the official guidance, backed by
50+
the core team, is to use a flat directory structure for modules. This ensures
51+
optimal performance.
52+
53+
Existing collections that still use deep directories can migrate to the flat
54+
structure in a backward-compatible way by adding redirects like in
55+
[this example](https://github.com/ansible-collections/community.general/blob/main/meta/runtime.yml#L227-L233).
56+
4457
## Problematic Code
4558

4659
```yaml

src/ansiblelint/rules/fqcn.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,29 @@ def matchtask(
173173
)
174174
return result
175175

176+
def matchyaml(self, file: Lintable) -> list[MatchError]:
177+
"""Return matches found for a specific YAML text."""
178+
result = []
179+
if file.kind == "plugin":
180+
i = file.path.resolve().parts.index("plugins")
181+
plugin_type = file.path.resolve().parts[i : i + 2]
182+
short_path = file.path.resolve().parts[i + 2 :]
183+
if len(short_path) > 1:
184+
result.append(
185+
self.create_matcherror(
186+
message=f"Deep plugins directory is discouraged. Move '{file.path}' directly under '{'/'.join(plugin_type)}' folder.",
187+
tag="fqcn[deep]",
188+
filename=file,
189+
),
190+
)
191+
elif file.kind == "playbook":
192+
for play in file.data:
193+
if play is None:
194+
continue
195+
196+
result.extend(self.matchplay(file, play))
197+
return result
198+
176199
def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]:
177200
if file.kind != "playbook":
178201
return []
@@ -241,3 +264,21 @@ def test_fqcn_builtin_pass() -> None:
241264
success = "examples/playbooks/rule-fqcn-pass.yml"
242265
results = Runner(success, rules=collection).run()
243266
assert len(results) == 0, results
267+
268+
def test_fqcn_deep_fail() -> None:
269+
"""Test rule matches."""
270+
collection = RulesCollection()
271+
collection.register(FQCNBuiltinsRule())
272+
failure = "examples/collection/plugins/modules/deep/beta.py"
273+
results = Runner(failure, rules=collection).run()
274+
assert len(results) == 1
275+
assert results[0].tag == "fqcn[deep]"
276+
assert "Deep plugins directory is discouraged" in results[0].message
277+
278+
def test_fqcn_deep_pass() -> None:
279+
"""Test rule does not match."""
280+
collection = RulesCollection()
281+
collection.register(FQCNBuiltinsRule())
282+
success = "examples/collection/plugins/modules/alpha.py"
283+
results = Runner(success, rules=collection).run()
284+
assert len(results) == 0

0 commit comments

Comments
 (0)