diff options
Diffstat (limited to '')
38 files changed, 1955 insertions, 0 deletions
diff --git a/lib/ansiblelint/rules/AlwaysRunRule.py b/lib/ansiblelint/rules/AlwaysRunRule.py new file mode 100644 index 0000000..8d811ff --- /dev/null +++ b/lib/ansiblelint/rules/AlwaysRunRule.py @@ -0,0 +1,33 @@ +# Copyright (c) 2017 Anth Courtney <anthcourtney@gmail.com> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from ansiblelint.rules import AnsibleLintRule + + +class AlwaysRunRule(AnsibleLintRule): + id = '101' + shortdesc = 'Deprecated always_run' + description = 'Instead of ``always_run``, use ``check_mode``' + severity = 'MEDIUM' + tags = ['deprecated', 'ANSIBLE0018'] + version_added = 'historic' + + def matchtask(self, file, task): + return 'always_run' in task diff --git a/lib/ansiblelint/rules/BecomeUserWithoutBecomeRule.py b/lib/ansiblelint/rules/BecomeUserWithoutBecomeRule.py new file mode 100644 index 0000000..e6f3259 --- /dev/null +++ b/lib/ansiblelint/rules/BecomeUserWithoutBecomeRule.py @@ -0,0 +1,80 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from functools import reduce + +from ansiblelint.rules import AnsibleLintRule + + +def _get_subtasks(data): + result = [] + block_names = [ + 'tasks', + 'pre_tasks', + 'post_tasks', + 'handlers', + 'block', + 'always', + 'rescue'] + for name in block_names: + if data and name in data: + result += (data[name] or []) + return result + + +def _nested_search(term, data): + if data and term in data: + return True + return reduce((lambda x, y: x or _nested_search(term, y)), _get_subtasks(data), False) + + +def _become_user_without_become(becomeuserabove, data): + if 'become' in data: + # If become is in lineage of tree then correct + return False + if ('become_user' in data and _nested_search('become', data)): + # If 'become_user' on tree and become somewhere below + # we must check for a case of a second 'become_user' without a + # 'become' in its lineage + subtasks = _get_subtasks(data) + return reduce((lambda x, y: x or _become_user_without_become(False, y)), subtasks, False) + if _nested_search('become_user', data): + # Keep searching down if 'become_user' exists in the tree below current task + subtasks = _get_subtasks(data) + return (len(subtasks) == 0 or + reduce((lambda x, y: x or + _become_user_without_become( + becomeuserabove or 'become_user' in data, y)), subtasks, False)) + # If at bottom of tree, flag up if 'become_user' existed in the lineage of the tree and + # 'become' was not. This is an error if any lineage has a 'become_user' but no become + return becomeuserabove + + +class BecomeUserWithoutBecomeRule(AnsibleLintRule): + id = '501' + shortdesc = 'become_user requires become to work as expected' + description = '``become_user`` without ``become`` will not actually change user' + severity = 'VERY_HIGH' + tags = ['task', 'oddity', 'ANSIBLE0017'] + version_added = 'historic' + + def matchplay(self, file, data): + if file['type'] == 'playbook' and _become_user_without_become(False, data): + return ({'become_user': data}, self.shortdesc) diff --git a/lib/ansiblelint/rules/CommandHasChangesCheckRule.py b/lib/ansiblelint/rules/CommandHasChangesCheckRule.py new file mode 100644 index 0000000..26087b8 --- /dev/null +++ b/lib/ansiblelint/rules/CommandHasChangesCheckRule.py @@ -0,0 +1,45 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from ansiblelint.rules import AnsibleLintRule + + +class CommandHasChangesCheckRule(AnsibleLintRule): + id = '301' + shortdesc = 'Commands should not change things if nothing needs doing' + description = ( + 'Commands should either read information (and thus set ' + '``changed_when``) or not do something if it has already been ' + 'done (using creates/removes) or only do it if another ' + 'check has a particular result (``when``)' + ) + severity = 'HIGH' + tags = ['command-shell', 'idempotency', 'ANSIBLE0012'] + version_added = 'historic' + + _commands = ['command', 'shell', 'raw'] + + def matchtask(self, file, task): + if task["__ansible_action_type__"] == 'task': + if task["action"]["__ansible_module__"] in self._commands: + return 'changed_when' not in task and \ + 'when' not in task and \ + 'creates' not in task['action'] and \ + 'removes' not in task['action'] diff --git a/lib/ansiblelint/rules/CommandsInsteadOfArgumentsRule.py b/lib/ansiblelint/rules/CommandsInsteadOfArgumentsRule.py new file mode 100644 index 0000000..f1adffa --- /dev/null +++ b/lib/ansiblelint/rules/CommandsInsteadOfArgumentsRule.py @@ -0,0 +1,65 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os + +from ansiblelint.rules import AnsibleLintRule +from ansiblelint.utils import get_first_cmd_arg + +try: + from ansible.module_utils.parsing.convert_bool import boolean +except ImportError: + try: + from ansible.utils.boolean import boolean + except ImportError: + try: + from ansible.utils import boolean + except ImportError: + from ansible import constants + boolean = constants.mk_boolean + + +class CommandsInsteadOfArgumentsRule(AnsibleLintRule): + id = '302' + shortdesc = 'Using command rather than an argument to e.g. file' + description = ( + 'Executing a command when there are arguments to modules ' + 'is generally a bad idea' + ) + severity = 'VERY_HIGH' + tags = ['command-shell', 'resources', 'ANSIBLE0007'] + version_added = 'historic' + + _commands = ['command', 'shell', 'raw'] + _arguments = {'chown': 'owner', 'chmod': 'mode', 'chgrp': 'group', + 'ln': 'state=link', 'mkdir': 'state=directory', + 'rmdir': 'state=absent', 'rm': 'state=absent'} + + def matchtask(self, file, task): + if task["action"]["__ansible_module__"] in self._commands: + first_cmd_arg = get_first_cmd_arg(task) + if not first_cmd_arg: + return + + executable = os.path.basename(first_cmd_arg) + if executable in self._arguments and \ + boolean(task['action'].get('warn', True)): + message = "{0} used in place of argument {1} to file module" + return message.format(executable, self._arguments[executable]) diff --git a/lib/ansiblelint/rules/CommandsInsteadOfModulesRule.py b/lib/ansiblelint/rules/CommandsInsteadOfModulesRule.py new file mode 100644 index 0000000..b19c5c2 --- /dev/null +++ b/lib/ansiblelint/rules/CommandsInsteadOfModulesRule.py @@ -0,0 +1,86 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os + +from ansiblelint.rules import AnsibleLintRule +from ansiblelint.utils import get_first_cmd_arg + +try: + from ansible.module_utils.parsing.convert_bool import boolean +except ImportError: + try: + from ansible.utils.boolean import boolean + except ImportError: + try: + from ansible.utils import boolean + except ImportError: + from ansible import constants + boolean = constants.mk_boolean + + +class CommandsInsteadOfModulesRule(AnsibleLintRule): + id = '303' + shortdesc = 'Using command rather than module' + description = ( + 'Executing a command when there is an Ansible module ' + 'is generally a bad idea' + ) + severity = 'HIGH' + tags = ['command-shell', 'resources', 'ANSIBLE0006'] + version_added = 'historic' + + _commands = ['command', 'shell'] + _modules = { + 'apt-get': 'apt-get', + 'chkconfig': 'service', + 'curl': 'get_url or uri', + 'git': 'git', + 'hg': 'hg', + 'letsencrypt': 'acme_certificate', + 'mktemp': 'tempfile', + 'mount': 'mount', + 'patch': 'patch', + 'rpm': 'yum or rpm_key', + 'rsync': 'synchronize', + 'sed': 'template, replace or lineinfile', + 'service': 'service', + 'supervisorctl': 'supervisorctl', + 'svn': 'subversion', + 'systemctl': 'systemd', + 'tar': 'unarchive', + 'unzip': 'unarchive', + 'wget': 'get_url or uri', + 'yum': 'yum', + } + + def matchtask(self, file, task): + if task['action']['__ansible_module__'] not in self._commands: + return + + first_cmd_arg = get_first_cmd_arg(task) + if not first_cmd_arg: + return + + executable = os.path.basename(first_cmd_arg) + if executable in self._modules and \ + boolean(task['action'].get('warn', True)): + message = '{0} used in place of {1} module' + return message.format(executable, self._modules[executable]) diff --git a/lib/ansiblelint/rules/ComparisonToEmptyStringRule.py b/lib/ansiblelint/rules/ComparisonToEmptyStringRule.py new file mode 100644 index 0000000..a43c4f7 --- /dev/null +++ b/lib/ansiblelint/rules/ComparisonToEmptyStringRule.py @@ -0,0 +1,23 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +import re + +from ansiblelint.rules import AnsibleLintRule + + +class ComparisonToEmptyStringRule(AnsibleLintRule): + id = '602' + shortdesc = "Don't compare to empty string" + description = ( + 'Use ``when: var|length > 0`` rather than ``when: var != ""`` (or ' + 'conversely ``when: var|length == 0`` rather than ``when: var == ""``)' + ) + severity = 'HIGH' + tags = ['idiom'] + version_added = 'v4.0.0' + + empty_string_compare = re.compile("[=!]= ?(\"{2}|'{2})") + + def match(self, file, line): + return self.empty_string_compare.search(line) diff --git a/lib/ansiblelint/rules/ComparisonToLiteralBoolRule.py b/lib/ansiblelint/rules/ComparisonToLiteralBoolRule.py new file mode 100644 index 0000000..46668d1 --- /dev/null +++ b/lib/ansiblelint/rules/ComparisonToLiteralBoolRule.py @@ -0,0 +1,23 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +import re + +from ansiblelint.rules import AnsibleLintRule + + +class ComparisonToLiteralBoolRule(AnsibleLintRule): + id = '601' + shortdesc = "Don't compare to literal True/False" + description = ( + 'Use ``when: var`` rather than ``when: var == True`` ' + '(or conversely ``when: not var``)' + ) + severity = 'HIGH' + tags = ['idiom'] + version_added = 'v4.0.0' + + literal_bool_compare = re.compile("[=!]= ?(True|true|False|false)") + + def match(self, file, line): + return self.literal_bool_compare.search(line) diff --git a/lib/ansiblelint/rules/DeprecatedModuleRule.py b/lib/ansiblelint/rules/DeprecatedModuleRule.py new file mode 100644 index 0000000..dc019ed --- /dev/null +++ b/lib/ansiblelint/rules/DeprecatedModuleRule.py @@ -0,0 +1,37 @@ +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class DeprecatedModuleRule(AnsibleLintRule): + id = '105' + shortdesc = 'Deprecated module' + description = ( + 'These are deprecated modules, some modules are kept ' + 'temporarily for backwards compatibility but usage is discouraged. ' + 'For more details see: ' + 'https://docs.ansible.com/ansible/latest/modules/list_of_all_modules.html' + ) + severity = 'HIGH' + tags = ['deprecated'] + version_added = 'v4.0.0' + + _modules = [ + 'accelerate', 'aos_asn_pool', 'aos_blueprint', 'aos_blueprint_param', + 'aos_blueprint_virtnet', 'aos_device', 'aos_external_router', + 'aos_ip_pool', 'aos_logical_device', 'aos_logical_device_map', + 'aos_login', 'aos_rack_type', 'aos_template', 'azure', 'cl_bond', + 'cl_bridge', 'cl_img_install', 'cl_interface', 'cl_interface_policy', + 'cl_license', 'cl_ports', 'cs_nic', 'docker', 'ec2_ami_find', + 'ec2_ami_search', 'ec2_remote_facts', 'ec2_vpc', 'kubernetes', + 'netscaler', 'nxos_ip_interface', 'nxos_mtu', 'nxos_portchannel', + 'nxos_switchport', 'oc', 'panos_nat_policy', 'panos_security_policy', + 'vsphere_guest', 'win_msi', 'include' + ] + + def matchtask(self, file, task): + module = task["action"]["__ansible_module__"] + if module in self._modules: + message = '{0} {1}' + return message.format(self.shortdesc, module) + return False diff --git a/lib/ansiblelint/rules/EnvVarsInCommandRule.py b/lib/ansiblelint/rules/EnvVarsInCommandRule.py new file mode 100644 index 0000000..58dba90 --- /dev/null +++ b/lib/ansiblelint/rules/EnvVarsInCommandRule.py @@ -0,0 +1,48 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from ansiblelint.rules import AnsibleLintRule +from ansiblelint.utils import FILENAME_KEY, LINE_NUMBER_KEY, get_first_cmd_arg + + +class EnvVarsInCommandRule(AnsibleLintRule): + id = '304' + shortdesc = "Environment variables don't work as part of command" + description = ( + 'Environment variables should be passed to ``shell`` or ``command`` ' + 'through environment argument' + ) + severity = 'VERY_HIGH' + tags = ['command-shell', 'bug', 'ANSIBLE0014'] + version_added = 'historic' + + expected_args = ['chdir', 'creates', 'executable', 'removes', 'stdin', 'warn', + 'stdin_add_newline', 'strip_empty_ends', + 'cmd', '__ansible_module__', '__ansible_arguments__', + LINE_NUMBER_KEY, FILENAME_KEY] + + def matchtask(self, file, task): + if task["action"]["__ansible_module__"] in ['command']: + first_cmd_arg = get_first_cmd_arg(task) + if not first_cmd_arg: + return + + return any([arg not in self.expected_args for arg in task['action']] + + ["=" in first_cmd_arg]) diff --git a/lib/ansiblelint/rules/GitHasVersionRule.py b/lib/ansiblelint/rules/GitHasVersionRule.py new file mode 100644 index 0000000..f0f3680 --- /dev/null +++ b/lib/ansiblelint/rules/GitHasVersionRule.py @@ -0,0 +1,37 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from ansiblelint.rules import AnsibleLintRule + + +class GitHasVersionRule(AnsibleLintRule): + id = '401' + shortdesc = 'Git checkouts must contain explicit version' + description = ( + 'All version control checkouts must point to ' + 'an explicit commit or tag, not just ``latest``' + ) + severity = 'MEDIUM' + tags = ['module', 'repeatability', 'ANSIBLE0004'] + version_added = 'historic' + + def matchtask(self, file, task): + return (task['action']['__ansible_module__'] == 'git' and + task['action'].get('version', 'HEAD') == 'HEAD') diff --git a/lib/ansiblelint/rules/IncludeMissingFileRule.py b/lib/ansiblelint/rules/IncludeMissingFileRule.py new file mode 100644 index 0000000..57508fa --- /dev/null +++ b/lib/ansiblelint/rules/IncludeMissingFileRule.py @@ -0,0 +1,67 @@ +# Copyright (c) 2020, Joachim Lusiardi +# Copyright (c) 2020, Ansible Project + +import os.path + +import ansible.parsing.yaml.objects + +from ansiblelint.rules import AnsibleLintRule + + +class IncludeMissingFileRule(AnsibleLintRule): + id = '505' + shortdesc = 'referenced files must exist' + description = ( + 'All files referenced by by include / import tasks ' + 'must exist. The check excludes files with jinja2 ' + 'templates in the filename.' + ) + severity = 'MEDIUM' + tags = ['task', 'bug'] + version_added = 'v4.3.0' + + def matchplay(self, file, data): + absolute_directory = file.get('absolute_directory', None) + results = [] + + # avoid failing with a playbook having tasks: null + for task in (data.get('tasks', []) or []): + + # ignore None tasks or + # if the id of the current rule is not in list of skipped rules for this play + if not task or self.id in task.get('skipped_rules', ()): + continue + + # collect information which file was referenced for include / import + referenced_file = None + for key, val in task.items(): + if not (key.startswith('include_') or + key.startswith('import_') or + key == 'include'): + continue + if isinstance(val, ansible.parsing.yaml.objects.AnsibleMapping): + referenced_file = val.get('file', None) + else: + referenced_file = val + # take the file and skip the remaining keys + if referenced_file: + break + + if referenced_file is None or absolute_directory is None: + continue + + # make sure we have a absolute path here and check if it is a file + referenced_file = os.path.join(absolute_directory, referenced_file) + + # skip if this is a jinja2 templated reference + if '{{' in referenced_file: + continue + + # existing files do not produce any error + if os.path.isfile(referenced_file): + continue + + results.append(({'referenced_file': referenced_file}, + 'referenced missing file in %s:%i' + % (task['__file__'], task['__line__']))) + return results diff --git a/lib/ansiblelint/rules/LineTooLongRule.py b/lib/ansiblelint/rules/LineTooLongRule.py new file mode 100644 index 0000000..007857e --- /dev/null +++ b/lib/ansiblelint/rules/LineTooLongRule.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class LineTooLongRule(AnsibleLintRule): + id = '204' + shortdesc = 'Lines should be no longer than 160 chars' + description = ( + 'Long lines make code harder to read and ' + 'code review more difficult' + ) + severity = 'VERY_LOW' + tags = ['formatting'] + version_added = 'v4.0.0' + + def match(self, file, line): + return len(line) > 160 diff --git a/lib/ansiblelint/rules/LoadingFailureRule.py b/lib/ansiblelint/rules/LoadingFailureRule.py new file mode 100644 index 0000000..7c37498 --- /dev/null +++ b/lib/ansiblelint/rules/LoadingFailureRule.py @@ -0,0 +1,14 @@ +"""Rule definition for a failure to load a file.""" + +from ansiblelint.rules import AnsibleLintRule + + +class LoadingFailureRule(AnsibleLintRule): + """File loading failure.""" + + id = '901' + shortdesc = 'Failed to load or parse file' + description = 'Linter failed to process a YAML file, possible not an Ansible file.' + severity = 'VERY_HIGH' + tags = ['core'] + version_added = 'v4.3.0' diff --git a/lib/ansiblelint/rules/MercurialHasRevisionRule.py b/lib/ansiblelint/rules/MercurialHasRevisionRule.py new file mode 100644 index 0000000..fcfe0a8 --- /dev/null +++ b/lib/ansiblelint/rules/MercurialHasRevisionRule.py @@ -0,0 +1,37 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from ansiblelint.rules import AnsibleLintRule + + +class MercurialHasRevisionRule(AnsibleLintRule): + id = '402' + shortdesc = 'Mercurial checkouts must contain explicit revision' + description = ( + 'All version control checkouts must point to ' + 'an explicit commit or tag, not just ``latest``' + ) + severity = 'MEDIUM' + tags = ['module', 'repeatability', 'ANSIBLE0005'] + version_added = 'historic' + + def matchtask(self, file, task): + return (task['action']['__ansible_module__'] == 'hg' and + task['action'].get('revision', 'default') == 'default') diff --git a/lib/ansiblelint/rules/MetaChangeFromDefaultRule.py b/lib/ansiblelint/rules/MetaChangeFromDefaultRule.py new file mode 100644 index 0000000..db52db3 --- /dev/null +++ b/lib/ansiblelint/rules/MetaChangeFromDefaultRule.py @@ -0,0 +1,40 @@ +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class MetaChangeFromDefaultRule(AnsibleLintRule): + id = '703' + shortdesc = 'meta/main.yml default values should be changed' + field_defaults = [ + ('author', 'your name'), + ('description', 'your description'), + ('company', 'your company (optional)'), + ('license', 'license (GPLv2, CC-BY, etc)'), + ('license', 'license (GPL-2.0-or-later, MIT, etc)'), + ] + description = ( + 'meta/main.yml default values should be changed for: ``{}``'.format( + ', '.join(f[0] for f in field_defaults) + ) + ) + severity = 'HIGH' + tags = ['metadata'] + version_added = 'v4.0.0' + + def matchplay(self, file, data): + if file['type'] != 'meta': + return False + + galaxy_info = data.get('galaxy_info', None) + if not galaxy_info: + return False + + results = [] + for field, default in self.field_defaults: + value = galaxy_info.get(field, None) + if value and value == default: + results.append(({'meta/main.yml': data}, + 'Should change default metadata: %s' % field)) + + return results diff --git a/lib/ansiblelint/rules/MetaMainHasInfoRule.py b/lib/ansiblelint/rules/MetaMainHasInfoRule.py new file mode 100644 index 0000000..f05f240 --- /dev/null +++ b/lib/ansiblelint/rules/MetaMainHasInfoRule.py @@ -0,0 +1,66 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + +META_STR_INFO = ( + 'author', + 'description' +) +META_INFO = tuple(list(META_STR_INFO) + [ + 'license', + 'min_ansible_version', + 'platforms', +]) + + +def _platform_info_errors_itr(platforms): + if not isinstance(platforms, list): + yield 'Platforms should be a list of dictionaries' + return + + for platform in platforms: + if not isinstance(platform, dict): + yield 'Platforms should be a list of dictionaries' + elif 'name' not in platform: + yield 'Platform should contain name' + + +def _galaxy_info_errors_itr(galaxy_info, + info_list=META_INFO, + str_info_list=META_STR_INFO): + for info in info_list: + ginfo = galaxy_info.get(info, False) + if ginfo: + if info in str_info_list and not isinstance(ginfo, str): + yield '{info} should be a string'.format(info=info) + elif info == 'platforms': + for err in _platform_info_errors_itr(ginfo): + yield err + else: + yield 'Role info should contain {info}'.format(info=info) + + +class MetaMainHasInfoRule(AnsibleLintRule): + id = '701' + shortdesc = 'meta/main.yml should contain relevant info' + str_info = META_STR_INFO + info = META_INFO + description = ( + 'meta/main.yml should contain: ``{}``'.format(', '.join(info)) + ) + severity = 'HIGH' + tags = ['metadata'] + version_added = 'v4.0.0' + + def matchplay(self, file, data): + if file['type'] != 'meta': + return False + + meta = {'meta/main.yml': data} + galaxy_info = data.get('galaxy_info', False) + if galaxy_info: + return [(meta, err) for err + in _galaxy_info_errors_itr(galaxy_info)] + + return [(meta, "No 'galaxy_info' found")] diff --git a/lib/ansiblelint/rules/MetaTagValidRule.py b/lib/ansiblelint/rules/MetaTagValidRule.py new file mode 100644 index 0000000..0739ca3 --- /dev/null +++ b/lib/ansiblelint/rules/MetaTagValidRule.py @@ -0,0 +1,81 @@ +# Copyright (c) 2018, Ansible Project + +import re +import sys + +from ansiblelint.rules import AnsibleLintRule + + +class MetaTagValidRule(AnsibleLintRule): + id = '702' + shortdesc = 'Tags must contain lowercase letters and digits only' + description = ( + 'Tags must contain lowercase letters and digits only, ' + 'and ``galaxy_tags`` is expected to be a list' + ) + severity = 'HIGH' + tags = ['metadata'] + version_added = 'v4.0.0' + + TAG_REGEXP = re.compile('^[a-z0-9]+$') + + def matchplay(self, file, data): + if file['type'] != 'meta': + return False + + galaxy_info = data.get('galaxy_info', None) + if not galaxy_info: + return False + + tags = [] + results = [] + + if 'galaxy_tags' in galaxy_info: + if isinstance(galaxy_info['galaxy_tags'], list): + tags += galaxy_info['galaxy_tags'] + else: + results.append(({'meta/main.yml': data}, + "Expected 'galaxy_tags' to be a list")) + + if 'categories' in galaxy_info: + results.append(({'meta/main.yml': data}, + "Use 'galaxy_tags' rather than 'categories'")) + if isinstance(galaxy_info['categories'], list): + tags += galaxy_info['categories'] + else: + results.append(({'meta/main.yml': data}, + "Expected 'categories' to be a list")) + + for tag in tags: + msg = self.shortdesc + if not isinstance(tag, str): + results.append(( + {'meta/main.yml': data}, + "Tags must be strings: '{}'".format(tag))) + continue + if not re.match(self.TAG_REGEXP, tag): + results.append(({'meta/main.yml': data}, + "{}, invalid: '{}'".format(msg, tag))) + + return results + + +META_TAG_VALID = ''' +galaxy_info: + galaxy_tags: ['database', 'my s q l', 'MYTAG'] + categories: 'my_category_not_in_a_list' +''' + +# testing code to be loaded only with pytest or when executed the rule file +if "pytest" in sys.modules: + + import pytest + + @pytest.mark.parametrize('rule_runner', (MetaTagValidRule, ), indirect=['rule_runner']) + def test_valid_tag_rule(rule_runner): + """Test rule matches.""" + results = rule_runner.run_role_meta_main(META_TAG_VALID) + assert "Use 'galaxy_tags' rather than 'categories'" in str(results) + assert "Expected 'categories' to be a list" in str(results) + assert "invalid: 'my s q l'" in str(results) + assert "invalid: 'MYTAG'" in str(results) diff --git a/lib/ansiblelint/rules/MetaVideoLinksRule.py b/lib/ansiblelint/rules/MetaVideoLinksRule.py new file mode 100644 index 0000000..aa34012 --- /dev/null +++ b/lib/ansiblelint/rules/MetaVideoLinksRule.py @@ -0,0 +1,65 @@ +# Copyright (c) 2018, Ansible Project + +import re + +from ansiblelint.rules import AnsibleLintRule + + +class MetaVideoLinksRule(AnsibleLintRule): + id = '704' + shortdesc = "meta/main.yml video_links should be formatted correctly" + description = ( + 'Items in ``video_links`` in meta/main.yml should be ' + 'dictionaries, and contain only keys ``url`` and ``title``, ' + 'and have a shared link from a supported provider' + ) + severity = 'LOW' + tags = ['metadata'] + version_added = 'v4.0.0' + + VIDEO_REGEXP = { + 'google': re.compile( + r'https://drive\.google\.com.*file/d/([0-9A-Za-z-_]+)/.*'), + 'vimeo': re.compile( + r'https://vimeo\.com/([0-9]+)'), + 'youtube': re.compile( + r'https://youtu\.be/([0-9A-Za-z-_]+)'), + } + + def matchplay(self, file, data): + if file['type'] != 'meta': + return False + + galaxy_info = data.get('galaxy_info', None) + if not galaxy_info: + return False + + video_links = galaxy_info.get('video_links', None) + if not video_links: + return False + + results = [] + + for video in video_links: + if not isinstance(video, dict): + results.append(({'meta/main.yml': data}, + "Expected item in 'video_links' to be " + "a dictionary")) + continue + + if set(video) != {'url', 'title', '__file__', '__line__'}: + results.append(({'meta/main.yml': data}, + "Expected item in 'video_links' to contain " + "only keys 'url' and 'title'")) + continue + + for name, expr in self.VIDEO_REGEXP.items(): + if expr.match(video['url']): + break + else: + msg = ("URL format '{0}' is not recognized. " + "Expected it be a shared link from Vimeo, YouTube, " + "or Google Drive.".format(video['url'])) + results.append(({'meta/main.yml': data}, msg)) + + return results diff --git a/lib/ansiblelint/rules/MissingFilePermissionsRule.py b/lib/ansiblelint/rules/MissingFilePermissionsRule.py new file mode 100644 index 0000000..bc11cc7 --- /dev/null +++ b/lib/ansiblelint/rules/MissingFilePermissionsRule.py @@ -0,0 +1,95 @@ +# Copyright (c) 2020 Sorin Sbarnea <sorin.sbarnea@gmail.com> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from ansiblelint.rules import AnsibleLintRule + +# Despite documentation mentioning 'preserve' only these modules support it: +_modules_with_preserve = ( + 'copy', + 'template', +) + + +class MissingFilePermissionsRule(AnsibleLintRule): + id = "208" + shortdesc = 'File permissions unset or incorrect' + description = ( + "Missing or unsupported mode parameter can cause unexpected file " + "permissions based " + "on version of Ansible being used. Be explicit, like ``mode: 0644`` to " + "avoid hitting this rule. Special ``preserve`` value is accepted " + f"only by {', '.join(_modules_with_preserve)} modules. " + "See https://github.com/ansible/ansible/issues/71200" + ) + severity = 'VERY_HIGH' + tags = ['unpredictability', 'experimental'] + version_added = 'v4.3.0' + + _modules = { + 'archive', + 'assemble', + 'copy', # supports preserve + 'file', + 'replace', # implicit preserve behavior but mode: preserve is invalid + 'template', # supports preserve + # 'unarchive', # disabled because .tar.gz files can have permissions inside + } + + _modules_with_create = { + 'blockinfile': False, + 'htpasswd': True, + 'ini_file': True, + 'lineinfile': False, + } + + def matchtask(self, file, task): + module = task["action"]["__ansible_module__"] + mode = task['action'].get('mode', None) + + if module not in self._modules and \ + module not in self._modules_with_create: + return False + + if mode == 'preserve' and module not in _modules_with_preserve: + return True + + if module in self._modules_with_create: + create = task["action"].get("create", self._modules_with_create[module]) + return create and mode is None + + # A file that doesn't exist cannot have a mode + if task['action'].get('state', None) == "absent": + return False + + # A symlink always has mode 0o777 + if task['action'].get('state', None) == "link": + return False + + # The file module does not create anything when state==file (default) + if module == "file" and \ + task['action'].get('state', 'file') == 'file': + return False + + # replace module is the only one that has a valid default preserve + # behavior, but we want to trigger rule if user used incorrect + # documentation and put 'preserve', which is not supported. + if module == 'replace' and mode is None: + return False + + return mode is None diff --git a/lib/ansiblelint/rules/NestedJinjaRule.py b/lib/ansiblelint/rules/NestedJinjaRule.py new file mode 100644 index 0000000..c10d4ec --- /dev/null +++ b/lib/ansiblelint/rules/NestedJinjaRule.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Author: Adrián Tóth <adtoth@redhat.com> +# +# Copyright (c) 2020, Red Hat, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import re + +from ansiblelint.rules import AnsibleLintRule + + +class NestedJinjaRule(AnsibleLintRule): + id = '207' + shortdesc = 'Nested jinja pattern' + description = ( + "There should not be any nested jinja pattern. " + "Example (bad): ``{{ list_one + {{ list_two | max }} }}``, " + "example (good): ``{{ list_one + max(list_two) }}``" + ) + severity = 'VERY_HIGH' + tags = ['formatting'] + version_added = 'v4.3.0' + + pattern = re.compile(r"{{(?:[^{}]*)?{{") + + def matchtask(self, file, task): + + command = "".join( + str(value) + # task properties are stored in the 'action' key + for key, value in task['action'].items() + # exclude useless values of '__file__', '__ansible_module__', '__*__', etc. + if not key.startswith('__') and not key.endswith('__') + ) + + return bool(self.pattern.search(command)) diff --git a/lib/ansiblelint/rules/NoFormattingInWhenRule.py b/lib/ansiblelint/rules/NoFormattingInWhenRule.py new file mode 100644 index 0000000..a665311 --- /dev/null +++ b/lib/ansiblelint/rules/NoFormattingInWhenRule.py @@ -0,0 +1,34 @@ +from ansiblelint.rules import AnsibleLintRule + + +class NoFormattingInWhenRule(AnsibleLintRule): + id = '102' + shortdesc = 'No Jinja2 in when' + description = '``when`` lines should not include Jinja2 variables' + severity = 'HIGH' + tags = ['deprecated', 'ANSIBLE0019'] + version_added = 'historic' + + def _is_valid(self, when): + if not isinstance(when, str): + return True + return when.find('{{') == -1 and when.find('}}') == -1 + + def matchplay(self, file, play): + errors = [] + if isinstance(play, dict): + if 'roles' not in play or play['roles'] is None: + return errors + for role in play['roles']: + if self.matchtask(file, role): + errors.append(({'when': role}, + 'role "when" clause has Jinja2 templates')) + if isinstance(play, list): + for play_item in play: + sub_errors = self.matchplay(file, play_item) + if sub_errors: + errors = errors + sub_errors + return errors + + def matchtask(self, file, task): + return 'when' in task and not self._is_valid(task['when']) diff --git a/lib/ansiblelint/rules/NoTabsRule.py b/lib/ansiblelint/rules/NoTabsRule.py new file mode 100644 index 0000000..78222c8 --- /dev/null +++ b/lib/ansiblelint/rules/NoTabsRule.py @@ -0,0 +1,16 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class NoTabsRule(AnsibleLintRule): + id = '203' + shortdesc = 'Most files should not contain tabs' + description = 'Tabs can cause unexpected display issues, use spaces' + severity = 'LOW' + tags = ['formatting'] + version_added = 'v4.0.0' + + def match(self, file, line): + return '\t' in line diff --git a/lib/ansiblelint/rules/OctalPermissionsRule.py b/lib/ansiblelint/rules/OctalPermissionsRule.py new file mode 100644 index 0000000..b95c322 --- /dev/null +++ b/lib/ansiblelint/rules/OctalPermissionsRule.py @@ -0,0 +1,73 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from ansiblelint.rules import AnsibleLintRule + + +class OctalPermissionsRule(AnsibleLintRule): + id = '202' + shortdesc = 'Octal file permissions must contain leading zero or be a string' + description = ( + 'Numeric file permissions without leading zero can behave ' + 'in unexpected ways. See ' + 'http://docs.ansible.com/ansible/file_module.html' + ) + severity = 'VERY_HIGH' + tags = ['formatting', 'ANSIBLE0009'] + version_added = 'historic' + + _modules = ['assemble', 'copy', 'file', 'ini_file', 'lineinfile', + 'replace', 'synchronize', 'template', 'unarchive'] + + def is_invalid_permission(self, mode): + # sensible file permission modes don't + # have write bit set when read bit is + # not set and don't have execute bit set + # when user execute bit is not set. + # also, user permissions are more generous than + # group permissions and user and group permissions + # are more generous than world permissions + + other_write_without_read = (mode % 8 and mode % 8 < 4 and + not (mode % 8 == 1 and (mode >> 6) % 2 == 1)) + group_write_without_read = ((mode >> 3) % 8 and (mode >> 3) % 8 < 4 and + not ((mode >> 3) % 8 == 1 and (mode >> 6) % 2 == 1)) + user_write_without_read = ((mode >> 6) % 8 and (mode >> 6) % 8 < 4 and + not (mode >> 6) % 8 == 1) + other_more_generous_than_group = mode % 8 > (mode >> 3) % 8 + other_more_generous_than_user = mode % 8 > (mode >> 6) % 8 + group_more_generous_than_user = (mode >> 3) % 8 > (mode >> 6) % 8 + + return (other_write_without_read or + group_write_without_read or + user_write_without_read or + other_more_generous_than_group or + other_more_generous_than_user or + group_more_generous_than_user) + + def matchtask(self, file, task): + if task["action"]["__ansible_module__"] in self._modules: + mode = task['action'].get('mode', None) + + if isinstance(mode, str): + return False + + if isinstance(mode, int): + return self.is_invalid_permission(mode) diff --git a/lib/ansiblelint/rules/PackageIsNotLatestRule.py b/lib/ansiblelint/rules/PackageIsNotLatestRule.py new file mode 100644 index 0000000..9fddaf4 --- /dev/null +++ b/lib/ansiblelint/rules/PackageIsNotLatestRule.py @@ -0,0 +1,67 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from ansiblelint.rules import AnsibleLintRule + + +class PackageIsNotLatestRule(AnsibleLintRule): + id = '403' + shortdesc = 'Package installs should not use latest' + description = ( + 'Package installs should use ``state=present`` ' + 'with or without a version' + ) + severity = 'VERY_LOW' + tags = ['module', 'repeatability', 'ANSIBLE0010'] + version_added = 'historic' + + _package_managers = [ + 'apk', + 'apt', + 'bower', + 'bundler', + 'dnf', + 'easy_install', + 'gem', + 'homebrew', + 'jenkins_plugin', + 'npm', + 'openbsd_package', + 'openbsd_pkg', + 'package', + 'pacman', + 'pear', + 'pip', + 'pkg5', + 'pkgutil', + 'portage', + 'slackpkg', + 'sorcery', + 'swdepot', + 'win_chocolatey', + 'yarn', + 'yum', + 'zypper', + ] + + def matchtask(self, file, task): + return (task['action']['__ansible_module__'] in self._package_managers and + not task['action'].get('version') and + task['action'].get('state') == 'latest') diff --git a/lib/ansiblelint/rules/PlaybookExtension.py b/lib/ansiblelint/rules/PlaybookExtension.py new file mode 100644 index 0000000..593e5ae --- /dev/null +++ b/lib/ansiblelint/rules/PlaybookExtension.py @@ -0,0 +1,28 @@ +# Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp> +# Copyright (c) 2018, Ansible Project + +import os +from typing import List + +from ansiblelint.rules import AnsibleLintRule + + +class PlaybookExtension(AnsibleLintRule): + id = '205' + shortdesc = 'Use ".yml" or ".yaml" playbook extension' + description = 'Playbooks should have the ".yml" or ".yaml" extension' + severity = 'MEDIUM' + tags = ['formatting'] + done = [] # type: List # already noticed path list + version_added = 'v4.0.0' + + def match(self, file, text): + if file['type'] != 'playbook': + return False + + path = file['path'] + ext = os.path.splitext(path) + if ext[1] not in ['.yml', '.yaml'] and path not in self.done: + self.done.append(path) + return True + return False diff --git a/lib/ansiblelint/rules/RoleNames.py b/lib/ansiblelint/rules/RoleNames.py new file mode 100644 index 0000000..3d790b3 --- /dev/null +++ b/lib/ansiblelint/rules/RoleNames.py @@ -0,0 +1,74 @@ +# Copyright (c) 2020 Gael Chamoulaud <gchamoul@redhat.com> +# Copyright (c) 2020 Sorin Sbarnea <ssbarnea@redhat.com> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import re +from pathlib import Path +from typing import List + +from ansiblelint.rules import AnsibleLintRule +from ansiblelint.utils import parse_yaml_from_file + +ROLE_NAME_REGEX = '^[a-z][a-z0-9_]+$' + + +def _remove_prefix(text, prefix): + return re.sub(r'^{0}'.format(re.escape(prefix)), '', text) + + +class RoleNames(AnsibleLintRule): + id = '106' + shortdesc = ( + "Role name {} does not match ``%s`` pattern" % ROLE_NAME_REGEX + ) + description = ( + "Role names are now limited to contain only lowercase alphanumeric " + "characters, plus '_' and start with an alpha character. See " + "`developing collections <https://docs.ansible.com/ansible/devel/dev_guide/developing_" + "collections.html#roles-directory>`_" + ) + severity = 'HIGH' + done: List[str] = [] # already noticed roles list + tags = ['deprecated'] + version_added = 'v4.3.0' + + ROLE_NAME_REGEXP = re.compile(ROLE_NAME_REGEX) + + def match(self, file, text): + path = file['path'].split("/") + if "tasks" in path: + role_name = _remove_prefix(path[path.index("tasks") - 1], "ansible-role-") + role_root = path[:path.index("tasks")] + meta = Path("/".join(role_root)) / "meta" / "main.yml" + + if meta.is_file(): + meta_data = parse_yaml_from_file(str(meta)) + if meta_data: + try: + role_name = meta_data['galaxy_info']['role_name'] + except KeyError: + pass + + if role_name in self.done: + return False + self.done.append(role_name) + if not re.match(self.ROLE_NAME_REGEXP, role_name): + return self.shortdesc.format(role_name) + return False diff --git a/lib/ansiblelint/rules/RoleRelativePath.py b/lib/ansiblelint/rules/RoleRelativePath.py new file mode 100644 index 0000000..87d7ac8 --- /dev/null +++ b/lib/ansiblelint/rules/RoleRelativePath.py @@ -0,0 +1,32 @@ +# Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp> +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class RoleRelativePath(AnsibleLintRule): + id = '404' + shortdesc = "Doesn't need a relative path in role" + description = '``copy`` and ``template`` do not need to use relative path for ``src``' + severity = 'HIGH' + tags = ['module'] + version_added = 'v4.0.0' + + _module_to_path_folder = { + 'copy': 'files', + 'win_copy': 'files', + 'template': 'templates', + 'win_template': 'win_templates', + } + + def matchtask(self, file, task): + module = task['action']['__ansible_module__'] + if module not in self._module_to_path_folder: + return False + + if 'src' not in task['action']: + return False + + path_to_check = '../{}'.format(self._module_to_path_folder[module]) + if path_to_check in task['action']['src']: + return True diff --git a/lib/ansiblelint/rules/ShellWithoutPipefail.py b/lib/ansiblelint/rules/ShellWithoutPipefail.py new file mode 100644 index 0000000..678e5a2 --- /dev/null +++ b/lib/ansiblelint/rules/ShellWithoutPipefail.py @@ -0,0 +1,38 @@ +import re + +from ansiblelint.rules import AnsibleLintRule + + +class ShellWithoutPipefail(AnsibleLintRule): + id = '306' + shortdesc = 'Shells that use pipes should set the pipefail option' + description = ( + 'Without the pipefail option set, a shell command that ' + 'implements a pipeline can fail and still return 0. If ' + 'any part of the pipeline other than the terminal command ' + 'fails, the whole pipeline will still return 0, which may ' + 'be considered a success by Ansible. ' + 'Pipefail is available in the bash shell.' + ) + severity = 'MEDIUM' + tags = ['command-shell'] + version_added = 'v4.1.0' + + _pipefail_re = re.compile(r"^\s*set.*[+-][A-z]*o\s*pipefail") + _pipe_re = re.compile(r"(?<!\|)\|(?!\|)") + + def matchtask(self, file, task): + if task["__ansible_action_type__"] != "task": + return False + + if task["action"]["__ansible_module__"] != "shell": + return False + + if task.get("ignore_errors"): + return False + + unjinjad_cmd = self.unjinja( + ' '.join(task["action"].get("__ansible_arguments__", []))) + + return (self._pipe_re.search(unjinjad_cmd) and + not self._pipefail_re.match(unjinjad_cmd)) diff --git a/lib/ansiblelint/rules/SudoRule.py b/lib/ansiblelint/rules/SudoRule.py new file mode 100644 index 0000000..8ea554e --- /dev/null +++ b/lib/ansiblelint/rules/SudoRule.py @@ -0,0 +1,36 @@ +from ansiblelint.rules import AnsibleLintRule + + +class SudoRule(AnsibleLintRule): + id = '103' + shortdesc = 'Deprecated sudo' + description = 'Instead of ``sudo``/``sudo_user``, use ``become``/``become_user``.' + severity = 'VERY_HIGH' + tags = ['deprecated', 'ANSIBLE0008'] + version_added = 'historic' + + def _check_value(self, play_frag): + results = [] + + if isinstance(play_frag, dict): + if 'sudo' in play_frag: + results.append(({'sudo': play_frag['sudo']}, + 'Deprecated sudo feature', play_frag['__line__'])) + if 'sudo_user' in play_frag: + results.append(({'sudo_user': play_frag['sudo_user']}, + 'Deprecated sudo_user feature', play_frag['__line__'])) + if 'tasks' in play_frag: + output = self._check_value(play_frag['tasks']) + if output: + results += output + + if isinstance(play_frag, list): + for item in play_frag: + output = self._check_value(item) + if output: + results += output + + return results + + def matchplay(self, file, play): + return self._check_value(play) diff --git a/lib/ansiblelint/rules/TaskHasNameRule.py b/lib/ansiblelint/rules/TaskHasNameRule.py new file mode 100644 index 0000000..8757b03 --- /dev/null +++ b/lib/ansiblelint/rules/TaskHasNameRule.py @@ -0,0 +1,40 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from ansiblelint.rules import AnsibleLintRule + + +class TaskHasNameRule(AnsibleLintRule): + id = '502' + shortdesc = 'All tasks should be named' + description = ( + 'All tasks should have a distinct name for readability ' + 'and for ``--start-at-task`` to work' + ) + severity = 'MEDIUM' + tags = ['task', 'readability', 'ANSIBLE0011'] + version_added = 'historic' + + _nameless_tasks = ['meta', 'debug', 'include_role', 'import_role', + 'include_tasks', 'import_tasks'] + + def matchtask(self, file, task): + return (not task.get('name') and + task["action"]["__ansible_module__"] not in self._nameless_tasks) diff --git a/lib/ansiblelint/rules/TaskNoLocalAction.py b/lib/ansiblelint/rules/TaskNoLocalAction.py new file mode 100644 index 0000000..294bb9d --- /dev/null +++ b/lib/ansiblelint/rules/TaskNoLocalAction.py @@ -0,0 +1,18 @@ +# Copyright (c) 2016, Tsukinowa Inc. <info@tsukinowa.jp> +# Copyright (c) 2018, Ansible Project + +from ansiblelint.rules import AnsibleLintRule + + +class TaskNoLocalAction(AnsibleLintRule): + id = '504' + shortdesc = "Do not use 'local_action', use 'delegate_to: localhost'" + description = 'Do not use ``local_action``, use ``delegate_to: localhost``' + severity = 'MEDIUM' + tags = ['task'] + version_added = 'v4.0.0' + + def match(self, file, text): + if 'local_action' in text: + return True + return False diff --git a/lib/ansiblelint/rules/TrailingWhitespaceRule.py b/lib/ansiblelint/rules/TrailingWhitespaceRule.py new file mode 100644 index 0000000..ac0f1c2 --- /dev/null +++ b/lib/ansiblelint/rules/TrailingWhitespaceRule.py @@ -0,0 +1,34 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from ansiblelint.rules import AnsibleLintRule + + +class TrailingWhitespaceRule(AnsibleLintRule): + id = '201' + shortdesc = 'Trailing whitespace' + description = 'There should not be any trailing whitespace' + severity = 'INFO' + tags = ['formatting', 'ANSIBLE0002'] + version_added = 'historic' + + def match(self, file, line): + line = line.replace("\r", "") + return line.rstrip() != line diff --git a/lib/ansiblelint/rules/UseCommandInsteadOfShellRule.py b/lib/ansiblelint/rules/UseCommandInsteadOfShellRule.py new file mode 100644 index 0000000..48babcf --- /dev/null +++ b/lib/ansiblelint/rules/UseCommandInsteadOfShellRule.py @@ -0,0 +1,45 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from ansiblelint.rules import AnsibleLintRule + + +class UseCommandInsteadOfShellRule(AnsibleLintRule): + id = '305' + shortdesc = 'Use shell only when shell functionality is required' + description = ( + 'Shell should only be used when piping, redirecting ' + 'or chaining commands (and Ansible would be preferred ' + 'for some of those!)' + ) + severity = 'HIGH' + tags = ['command-shell', 'safety', 'ANSIBLE0013'] + version_added = 'historic' + + def matchtask(self, file, task): + # Use unjinja so that we don't match on jinja filters + # rather than pipes + if task["action"]["__ansible_module__"] == 'shell': + if 'cmd' in task['action']: + unjinjad_cmd = self.unjinja(task["action"].get("cmd", [])) + else: + unjinjad_cmd = self.unjinja( + ' '.join(task["action"].get("__ansible_arguments__", []))) + return not any([ch in unjinjad_cmd for ch in '&|<>;$\n*[]{}?`']) diff --git a/lib/ansiblelint/rules/UseHandlerRatherThanWhenChangedRule.py b/lib/ansiblelint/rules/UseHandlerRatherThanWhenChangedRule.py new file mode 100644 index 0000000..53b389d --- /dev/null +++ b/lib/ansiblelint/rules/UseHandlerRatherThanWhenChangedRule.py @@ -0,0 +1,52 @@ +# Copyright (c) 2016 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from ansiblelint.rules import AnsibleLintRule + + +def _changed_in_when(item): + if not isinstance(item, str): + return False + return any(changed in item for changed in + ['.changed', '|changed', '["changed"]', "['changed']"]) + + +class UseHandlerRatherThanWhenChangedRule(AnsibleLintRule): + id = '503' + shortdesc = 'Tasks that run when changed should likely be handlers' + description = ( + 'If a task has a ``when: result.changed`` setting, it is effectively ' + 'acting as a handler' + ) + severity = 'MEDIUM' + tags = ['task', 'behaviour', 'ANSIBLE0016'] + version_added = 'historic' + + def matchtask(self, file, task): + if task["__ansible_action_type__"] != 'task': + return False + + when = task.get('when') + + if isinstance(when, list): + for item in when: + return _changed_in_when(item) + else: + return _changed_in_when(when) diff --git a/lib/ansiblelint/rules/UsingBareVariablesIsDeprecatedRule.py b/lib/ansiblelint/rules/UsingBareVariablesIsDeprecatedRule.py new file mode 100644 index 0000000..a0721ac --- /dev/null +++ b/lib/ansiblelint/rules/UsingBareVariablesIsDeprecatedRule.py @@ -0,0 +1,75 @@ +# Copyright (c) 2013-2014 Will Thames <will@thames.id.au> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os +import re + +from ansiblelint.rules import AnsibleLintRule + + +class UsingBareVariablesIsDeprecatedRule(AnsibleLintRule): + id = '104' + shortdesc = 'Using bare variables is deprecated' + description = ( + 'Using bare variables is deprecated. Update your ' + 'playbooks so that the environment value uses the full variable ' + 'syntax ``{{ your_variable }}``' + ) + severity = 'VERY_HIGH' + tags = ['deprecated', 'formatting', 'ANSIBLE0015'] + version_added = 'historic' + + _jinja = re.compile(r"{{.*}}", re.DOTALL) + _glob = re.compile('[][*?]') + + def matchtask(self, file, task): + loop_type = next((key for key in task + if key.startswith("with_")), None) + if loop_type: + if loop_type in ["with_nested", "with_together", "with_flattened", "with_filetree"]: + # These loops can either take a list defined directly in the task + # or a variable that is a list itself. When a single variable is used + # we just need to check that one variable, and not iterate over it like + # it's a list. Otherwise, loop through and check all items. + items = task[loop_type] + if not isinstance(items, (list, tuple)): + items = [items] + for var in items: + return self._matchvar(var, task, loop_type) + elif loop_type == "with_subelements": + return self._matchvar(task[loop_type][0], task, loop_type) + elif loop_type in ["with_sequence", "with_ini", + "with_inventory_hostnames"]: + pass + else: + return self._matchvar(task[loop_type], task, loop_type) + + def _matchvar(self, varstring, task, loop_type): + if (isinstance(varstring, str) and + not self._jinja.match(varstring)): + valid = loop_type == 'with_fileglob' and bool(self._jinja.search(varstring) or + self._glob.search(varstring)) + + valid |= loop_type == 'with_filetree' and bool(self._jinja.search(varstring) or + varstring.endswith(os.sep)) + if not valid: + message = "Found a bare variable '{0}' used in a '{1}' loop." + \ + " You should use the full variable syntax ('{{{{ {0} }}}}')" + return message.format(task[loop_type], loop_type) diff --git a/lib/ansiblelint/rules/VariableHasSpacesRule.py b/lib/ansiblelint/rules/VariableHasSpacesRule.py new file mode 100644 index 0000000..dd4f441 --- /dev/null +++ b/lib/ansiblelint/rules/VariableHasSpacesRule.py @@ -0,0 +1,24 @@ +# Copyright (c) 2016, Will Thames and contributors +# Copyright (c) 2018, Ansible Project + +import re + +from ansiblelint.rules import AnsibleLintRule + + +class VariableHasSpacesRule(AnsibleLintRule): + id = '206' + shortdesc = 'Variables should have spaces before and after: {{ var_name }}' + description = 'Variables should have spaces before and after: ``{{ var_name }}``' + severity = 'LOW' + tags = ['formatting'] + version_added = 'v4.0.0' + + variable_syntax = re.compile(r"{{.*}}") + bracket_regex = re.compile(r"{{[^{' -]|[^ '}-]}}") + + def match(self, file, line): + if not self.variable_syntax.search(line): + return + line_exclude_json = re.sub(r"[^{]{'\w+': ?[^{]{.*?}}", "", line) + return self.bracket_regex.search(line_exclude_json) diff --git a/lib/ansiblelint/rules/__init__.py b/lib/ansiblelint/rules/__init__.py new file mode 100644 index 0000000..fd3e92d --- /dev/null +++ b/lib/ansiblelint/rules/__init__.py @@ -0,0 +1,254 @@ +"""All internal ansible-lint rules.""" +import glob +import importlib.util +import logging +import os +import re +from collections import defaultdict +from importlib.abc import Loader +from time import sleep +from typing import List + +import ansiblelint.utils +from ansiblelint.errors import MatchError +from ansiblelint.skip_utils import append_skipped_rules, get_rule_skips_from_line + +_logger = logging.getLogger(__name__) + + +class AnsibleLintRule(object): + + def __repr__(self) -> str: + """Return a AnsibleLintRule instance representation.""" + return self.id + ": " + self.shortdesc + + def verbose(self) -> str: + return self.id + ": " + self.shortdesc + "\n " + self.description + + id: str = "" + tags: List[str] = [] + shortdesc: str = "" + description: str = "" + version_added: str = "" + severity: str = "" + match = None + matchtask = None + matchplay = None + + @staticmethod + def unjinja(text): + text = re.sub(r"{{.+?}}", "JINJA_EXPRESSION", text) + text = re.sub(r"{%.+?%}", "JINJA_STATEMENT", text) + text = re.sub(r"{#.+?#}", "JINJA_COMMENT", text) + return text + + def matchlines(self, file, text) -> List[MatchError]: + matches: List[MatchError] = [] + if not self.match: + return matches + # arrays are 0-based, line numbers are 1-based + # so use prev_line_no as the counter + for (prev_line_no, line) in enumerate(text.split("\n")): + if line.lstrip().startswith('#'): + continue + + rule_id_list = get_rule_skips_from_line(line) + if self.id in rule_id_list: + continue + + result = self.match(file, line) + if not result: + continue + message = None + if isinstance(result, str): + message = result + m = MatchError( + message=message, + linenumber=prev_line_no + 1, + details=line, + filename=file['path'], + rule=self) + matches.append(m) + return matches + + # TODO(ssbarnea): Reduce mccabe complexity + # https://github.com/ansible/ansible-lint/issues/744 + def matchtasks(self, file: str, text: str) -> List[MatchError]: # noqa: C901 + matches: List[MatchError] = [] + if not self.matchtask: + return matches + + if file['type'] == 'meta': + return matches + + yaml = ansiblelint.utils.parse_yaml_linenumbers(text, file['path']) + if not yaml: + return matches + + yaml = append_skipped_rules(yaml, text, file['type']) + + try: + tasks = ansiblelint.utils.get_normalized_tasks(yaml, file) + except MatchError as e: + return [e] + + for task in tasks: + if self.id in task.get('skipped_rules', ()): + continue + + if 'action' not in task: + continue + result = self.matchtask(file, task) + if not result: + continue + + message = None + if isinstance(result, str): + message = result + task_msg = "Task/Handler: " + ansiblelint.utils.task_to_str(task) + m = MatchError( + message=message, + linenumber=task[ansiblelint.utils.LINE_NUMBER_KEY], + details=task_msg, + filename=file['path'], + rule=self) + matches.append(m) + return matches + + @staticmethod + def _matchplay_linenumber(play, optional_linenumber): + try: + linenumber, = optional_linenumber + except ValueError: + linenumber = play[ansiblelint.utils.LINE_NUMBER_KEY] + return linenumber + + def matchyaml(self, file: str, text: str) -> List[MatchError]: + matches: List[MatchError] = [] + if not self.matchplay: + return matches + + yaml = ansiblelint.utils.parse_yaml_linenumbers(text, file['path']) + if not yaml: + return matches + + if isinstance(yaml, dict): + yaml = [yaml] + + yaml = ansiblelint.skip_utils.append_skipped_rules(yaml, text, file['type']) + + for play in yaml: + if self.id in play.get('skipped_rules', ()): + continue + + result = self.matchplay(file, play) + if not result: + continue + + if isinstance(result, tuple): + result = [result] + + if not isinstance(result, list): + raise TypeError("{} is not a list".format(result)) + + for section, message, *optional_linenumber in result: + linenumber = self._matchplay_linenumber(play, optional_linenumber) + m = MatchError( + message=message, + linenumber=linenumber, + details=str(section), + filename=file['path'], + rule=self) + matches.append(m) + return matches + + +def load_plugins(directory: str) -> List[AnsibleLintRule]: + """Return a list of rule classes.""" + result = [] + + for pluginfile in glob.glob(os.path.join(directory, '[A-Za-z]*.py')): + + pluginname = os.path.basename(pluginfile.replace('.py', '')) + spec = importlib.util.spec_from_file_location(pluginname, pluginfile) + # https://github.com/python/typeshed/issues/2793 + if spec and isinstance(spec.loader, Loader): + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + obj = getattr(module, pluginname)() + result.append(obj) + return result + + +class RulesCollection(object): + + def __init__(self, rulesdirs=None) -> None: + """Initialize a RulesCollection instance.""" + if rulesdirs is None: + rulesdirs = [] + self.rulesdirs = ansiblelint.utils.expand_paths_vars(rulesdirs) + self.rules: List[AnsibleLintRule] = [] + for rulesdir in self.rulesdirs: + _logger.debug("Loading rules from %s", rulesdir) + self.extend(load_plugins(rulesdir)) + self.rules = sorted(self.rules, key=lambda r: r.id) + + def register(self, obj: AnsibleLintRule): + self.rules.append(obj) + + def __iter__(self): + """Return the iterator over the rules in the RulesCollection.""" + return iter(self.rules) + + def __len__(self): + """Return the length of the RulesCollection data.""" + return len(self.rules) + + def extend(self, more: List[AnsibleLintRule]) -> None: + self.rules.extend(more) + + def run(self, playbookfile, tags=set(), skip_list=frozenset()) -> List: + text = "" + matches: List = list() + + for i in range(3): + try: + with open(playbookfile['path'], mode='r', encoding='utf-8') as f: + text = f.read() + break + except IOError as e: + _logger.warning( + "Couldn't open %s - %s [try:%s]", + playbookfile['path'], + e.strerror, + i) + sleep(1) + continue + if i and not text: + return matches + + for rule in self.rules: + if not tags or not set(rule.tags).union([rule.id]).isdisjoint(tags): + rule_definition = set(rule.tags) + rule_definition.add(rule.id) + if set(rule_definition).isdisjoint(skip_list): + matches.extend(rule.matchlines(playbookfile, text)) + matches.extend(rule.matchtasks(playbookfile, text)) + matches.extend(rule.matchyaml(playbookfile, text)) + + return matches + + def __repr__(self) -> str: + """Return a RulesCollection instance representation.""" + return "\n".join([rule.verbose() + for rule in sorted(self.rules, key=lambda x: x.id)]) + + def listtags(self) -> str: + tags = defaultdict(list) + for rule in self.rules: + for tag in rule.tags: + tags[tag].append("[{0}]".format(rule.id)) + results = [] + for tag in sorted(tags): + results.append("{0} {1}".format(tag, tags[tag])) + return "\n".join(results) diff --git a/lib/ansiblelint/rules/custom/__init__.py b/lib/ansiblelint/rules/custom/__init__.py new file mode 100644 index 0000000..8c3e048 --- /dev/null +++ b/lib/ansiblelint/rules/custom/__init__.py @@ -0,0 +1 @@ +"""A placeholder package for putting custom rules under this dir.""" |