diff options
Diffstat (limited to '')
-rw-r--r-- | src/ansiblelint/rules/var_naming.py | 370 |
1 files changed, 370 insertions, 0 deletions
diff --git a/src/ansiblelint/rules/var_naming.py b/src/ansiblelint/rules/var_naming.py new file mode 100644 index 0000000..389530d --- /dev/null +++ b/src/ansiblelint/rules/var_naming.py @@ -0,0 +1,370 @@ +"""Implementation of var-naming rule.""" +from __future__ import annotations + +import keyword +import re +import sys +from typing import TYPE_CHECKING, Any + +from ansible.parsing.yaml.objects import AnsibleUnicode +from ansible.vars.reserved import get_reserved_names + +from ansiblelint.config import options +from ansiblelint.constants import ANNOTATION_KEYS, LINE_NUMBER_KEY, RC +from ansiblelint.errors import MatchError +from ansiblelint.file_utils import Lintable +from ansiblelint.rules import AnsibleLintRule, RulesCollection +from ansiblelint.runner import Runner +from ansiblelint.skip_utils import get_rule_skips_from_line +from ansiblelint.utils import parse_yaml_from_file + +if TYPE_CHECKING: + from ansiblelint.utils import Task + + +class VariableNamingRule(AnsibleLintRule): + """All variables should be named using only lowercase and underscores.""" + + id = "var-naming" + severity = "MEDIUM" + tags = ["idiom"] + version_added = "v5.0.10" + needs_raw_task = True + re_pattern_str = options.var_naming_pattern or "^[a-z_][a-z0-9_]*$" + re_pattern = re.compile(re_pattern_str) + reserved_names = get_reserved_names() + # List of special variables that should be treated as read-only. This list + # does not include connection variables, which we expect users to tune in + # specific cases. + # https://docs.ansible.com/ansible/latest/reference_appendices/special_variables.html + read_only_names = { + "ansible_check_mode", + "ansible_collection_name", + "ansible_config_file", + "ansible_dependent_role_names", + "ansible_diff_mode", + "ansible_forks", + "ansible_index_var", + "ansible_inventory_sources", + "ansible_limit", + "ansible_local", # special fact + "ansible_loop", + "ansible_loop_var", + "ansible_parent_role_names", + "ansible_parent_role_paths", + "ansible_play_batch", + "ansible_play_hosts", + "ansible_play_hosts_all", + "ansible_play_name", + "ansible_play_role_names", + "ansible_playbook_python", + "ansible_role_name", + "ansible_role_names", + "ansible_run_tags", + "ansible_search_path", + "ansible_skip_tags", + "ansible_verbosity", + "ansible_version", + "group_names", + "groups", + "hostvars", + "inventory_dir", + "inventory_file", + "inventory_hostname", + "inventory_hostname_short", + "omit", + "play_hosts", + "playbook_dir", + "role_name", + "role_names", + "role_path", + } + + # These special variables are used by Ansible but we allow users to set + # them as they might need it in certain cases. + allowed_special_names = { + "ansible_facts", + "ansible_become_user", + "ansible_connection", + "ansible_host", + "ansible_python_interpreter", + "ansible_user", + "ansible_remote_tmp", # no included in docs + } + _ids = { + "var-naming[no-reserved]": "Variables names must not be Ansible reserved names.", + "var-naming[no-jinja]": "Variables names must not contain jinja2 templating.", + "var-naming[pattern]": f"Variables names should match {re_pattern_str} regex.", + } + + # pylint: disable=too-many-return-statements + def get_var_naming_matcherror( + self, + ident: str, + *, + prefix: str = "", + ) -> MatchError | None: + """Return a MatchError if the variable name is not valid, otherwise None.""" + if not isinstance(ident, str): # pragma: no cover + return MatchError( + tag="var-naming[non-string]", + message="Variables names must be strings.", + rule=self, + ) + + if ident in ANNOTATION_KEYS or ident in self.allowed_special_names: + return None + + try: + ident.encode("ascii") + except UnicodeEncodeError: + return MatchError( + tag="var-naming[non-ascii]", + message=f"Variables names must be ASCII. ({ident})", + rule=self, + ) + + if keyword.iskeyword(ident): + return MatchError( + tag="var-naming[no-keyword]", + message=f"Variables names must not be Python keywords. ({ident})", + rule=self, + ) + + if ident in self.reserved_names: + return MatchError( + tag="var-naming[no-reserved]", + message=f"Variables names must not be Ansible reserved names. ({ident})", + rule=self, + ) + + if ident in self.read_only_names: + return MatchError( + tag="var-naming[read-only]", + message=f"This special variable is read-only. ({ident})", + rule=self, + ) + + # We want to allow use of jinja2 templating for variable names + if "{{" in ident: + return MatchError( + tag="var-naming[no-jinja]", + message="Variables names must not contain jinja2 templating.", + rule=self, + ) + + if not bool(self.re_pattern.match(ident)): + return MatchError( + tag="var-naming[pattern]", + message=f"Variables names should match {self.re_pattern_str} regex. ({ident})", + rule=self, + ) + + if prefix and not ident.startswith(f"{prefix}_"): + return MatchError( + tag="var-naming[no-role-prefix]", + message="Variables names from within roles should use role_name_ as a prefix.", + rule=self, + ) + return None + + def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]: + """Return matches found for a specific playbook.""" + results: list[MatchError] = [] + raw_results: list[MatchError] = [] + + if not data or file.kind not in ("tasks", "handlers", "playbook", "vars"): + return results + # If the Play uses the 'vars' section to set variables + our_vars = data.get("vars", {}) + for key in our_vars: + match_error = self.get_var_naming_matcherror(key) + if match_error: + match_error.filename = str(file.path) + match_error.lineno = ( + key.ansible_pos[1] + if isinstance(key, AnsibleUnicode) + else our_vars[LINE_NUMBER_KEY] + ) + raw_results.append(match_error) + if raw_results: + lines = file.content.splitlines() + for match in raw_results: + # lineno starts with 1, not zero + skip_list = get_rule_skips_from_line( + line=lines[match.lineno - 1], + lintable=file, + ) + if match.rule.id not in skip_list and match.tag not in skip_list: + results.append(match) + + return results + + def matchtask( + self, + task: Task, + file: Lintable | None = None, + ) -> list[MatchError]: + """Return matches for task based variables.""" + results = [] + prefix = "" + filename = "" if file is None else str(file.path) + if file and file.parent and file.parent.kind == "role": + prefix = file.parent.path.name + ansible_module = task["action"]["__ansible_module__"] + # If the task uses the 'vars' section to set variables + our_vars = task.get("vars", {}) + if ansible_module in ("include_role", "import_role"): + action = task["action"] + if isinstance(action, dict): + role_fqcn = action.get("name", "") + prefix = role_fqcn.split("/" if "/" in role_fqcn else ".")[-1] + else: + prefix = "" + for key in our_vars: + match_error = self.get_var_naming_matcherror(key, prefix=prefix) + if match_error: + match_error.filename = filename + match_error.lineno = our_vars[LINE_NUMBER_KEY] + match_error.message += f" (vars: {key})" + results.append(match_error) + + # If the task uses the 'set_fact' module + if ansible_module == "set_fact": + for key in filter( + lambda x: isinstance(x, str) + and not x.startswith("__") + and x != "cacheable", + task["action"].keys(), + ): + match_error = self.get_var_naming_matcherror(key, prefix=prefix) + if match_error: + match_error.filename = filename + match_error.lineno = task["action"][LINE_NUMBER_KEY] + match_error.message += f" (set_fact: {key})" + results.append(match_error) + + # If the task registers a variable + registered_var = task.get("register", None) + if registered_var: + match_error = self.get_var_naming_matcherror(registered_var, prefix=prefix) + if match_error: + match_error.message += f" (register: {registered_var})" + match_error.filename = filename + match_error.lineno = task[LINE_NUMBER_KEY] + results.append(match_error) + + return results + + def matchyaml(self, file: Lintable) -> list[MatchError]: + """Return matches for variables defined in vars files.""" + results: list[MatchError] = [] + raw_results: list[MatchError] = [] + meta_data: dict[AnsibleUnicode, Any] = {} + filename = "" if file is None else str(file.path) + + if str(file.kind) == "vars" and file.data: + meta_data = parse_yaml_from_file(str(file.path)) + for key in meta_data: + match_error = self.get_var_naming_matcherror(key) + if match_error: + match_error.filename = filename + match_error.lineno = key.ansible_pos[1] + match_error.message += f" (vars: {key})" + raw_results.append(match_error) + if raw_results: + lines = file.content.splitlines() + for match in raw_results: + # lineno starts with 1, not zero + skip_list = get_rule_skips_from_line( + line=lines[match.lineno - 1], + lintable=file, + ) + if match.rule.id not in skip_list and match.tag not in skip_list: + results.append(match) + else: + results.extend(super().matchyaml(file)) + return results + + +# testing code to be loaded only with pytest or when executed the rule file +if "pytest" in sys.modules: + import pytest + + from ansiblelint.testing import ( # pylint: disable=ungrouped-imports + run_ansible_lint, + ) + + @pytest.mark.parametrize( + ("file", "expected"), + ( + pytest.param("examples/playbooks/rule-var-naming-fail.yml", 7, id="0"), + pytest.param("examples/Taskfile.yml", 0, id="1"), + ), + ) + def test_invalid_var_name_playbook(file: str, expected: int) -> None: + """Test rule matches.""" + rules = RulesCollection(options=options) + rules.register(VariableNamingRule()) + results = Runner(Lintable(file), rules=rules).run() + assert len(results) == expected + for result in results: + assert result.rule.id == VariableNamingRule.id + # We are not checking line numbers because they can vary between + # different versions of ruamel.yaml (and depending on presence/absence + # of its c-extension) + + def test_invalid_var_name_varsfile( + default_rules_collection: RulesCollection, + ) -> None: + """Test rule matches.""" + results = Runner( + Lintable("examples/playbooks/vars/rule_var_naming_fail.yml"), + rules=default_rules_collection, + ).run() + expected_errors = ( + ("schema[vars]", 1), + ("var-naming[pattern]", 2), + ("var-naming[pattern]", 6), + ("var-naming[no-jinja]", 7), + ("var-naming[no-keyword]", 9), + ("var-naming[non-ascii]", 10), + ("var-naming[no-reserved]", 11), + ("var-naming[read-only]", 12), + ) + assert len(results) == len(expected_errors) + for idx, result in enumerate(results): + assert result.tag == expected_errors[idx][0] + assert result.lineno == expected_errors[idx][1] + + def test_var_naming_with_pattern() -> None: + """Test rule matches.""" + role_path = "examples/roles/var_naming_pattern/tasks/main.yml" + conf_path = "examples/roles/var_naming_pattern/.ansible-lint" + result = run_ansible_lint( + f"--config-file={conf_path}", + role_path, + ) + assert result.returncode == RC.SUCCESS + assert "var-naming" not in result.stdout + + def test_var_naming_with_include_tasks_and_vars() -> None: + """Test with include tasks and vars.""" + role_path = "examples/roles/var_naming_pattern/tasks/include_task_with_vars.yml" + result = run_ansible_lint(role_path) + assert result.returncode == RC.SUCCESS + assert "var-naming" not in result.stdout + + def test_var_naming_with_set_fact_and_cacheable() -> None: + """Test with include tasks and vars.""" + role_path = "examples/roles/var_naming_pattern/tasks/cacheable_set_fact.yml" + result = run_ansible_lint(role_path) + assert result.returncode == RC.SUCCESS + assert "var-naming" not in result.stdout + + def test_var_naming_with_include_role_import_role() -> None: + """Test with include role and import role.""" + role_path = "examples/test_collection/roles/my_role/tasks/main.yml" + result = run_ansible_lint(role_path) + assert result.returncode == RC.SUCCESS + assert "var-naming" not in result.stdout |