ansible-later/ansiblelater/rules/ansiblefiles.py

372 lines
14 KiB
Python
Raw Normal View History

2019-04-10 12:50:48 +00:00
"""Checks related to ansible specific best practices."""
2018-12-19 10:19:07 +00:00
import os
2019-04-04 14:06:18 +00:00
import re
2018-12-19 10:19:07 +00:00
from collections import defaultdict
2019-04-04 14:06:18 +00:00
from ansiblelater.command.candidates import Error
from ansiblelater.command.candidates import Result
from ansiblelater.command.candidates import Template
2019-01-25 11:32:28 +00:00
from ansiblelater.utils import count_spaces
from ansiblelater.utils.rulehelper import get_normalized_tasks
from ansiblelater.utils.rulehelper import get_normalized_yaml
from ansiblelater.utils.rulehelper import get_first_cmd_arg
2018-12-19 10:19:07 +00:00
def check_braces_spaces(candidate, settings):
yamllines, errors = get_normalized_yaml(candidate, settings)
conf = settings["ansible"]["double-braces"]
description = "no suitable numbers of spaces (min: {min} max: {max})".format(
2020-04-05 12:33:43 +00:00
min=conf["min-spaces-inside"], max=conf["max-spaces-inside"]
)
2018-12-19 10:19:07 +00:00
matches = []
braces = re.compile("{{(.*?)}}")
if not errors:
for i, line in yamllines:
if "!unsafe" in line:
continue
2018-12-19 10:19:07 +00:00
match = braces.findall(line)
if match:
for item in match:
matches.append((i, item))
2018-12-19 10:19:07 +00:00
for i, line in matches:
[leading, trailing] = count_spaces(line)
sum_spaces = leading + trailing
2018-12-19 10:19:07 +00:00
2020-04-05 12:54:39 +00:00
if (
sum_spaces < conf["min-spaces-inside"] * 2
or sum_spaces > conf["min-spaces-inside"] * 2
):
errors.append(Error(i, description))
2018-12-19 10:19:07 +00:00
return Result(candidate.path, errors)
def check_named_task(candidate, settings):
tasks, errors = get_normalized_tasks(candidate, settings)
2020-04-05 12:33:43 +00:00
nameless_tasks = [
"meta", "debug", "include_role", "import_role", "include_tasks", "import_tasks",
"include_vars", "block"
]
2020-04-11 14:20:41 +00:00
description = "module '{module}' used without or empty name attribute"
2018-12-19 10:19:07 +00:00
if not errors:
for task in tasks:
module = task["action"]["__ansible_module__"]
2019-11-19 23:32:58 +00:00
if ("name" not in task or not task["name"]) and module not in nameless_tasks:
2020-04-11 14:20:41 +00:00
errors.append(Error(task["__line__"], description.format(module=module)))
2018-12-19 10:19:07 +00:00
return Result(candidate.path, errors)
def check_name_format(candidate, settings):
tasks, errors = get_normalized_tasks(candidate, settings)
2020-04-11 14:20:41 +00:00
description = "name '{name}' should start with uppercase"
2018-12-19 10:19:07 +00:00
namelines = defaultdict(list)
if not errors:
for task in tasks:
2019-04-04 14:06:18 +00:00
if "name" in task:
namelines[task["name"]].append(task["__line__"])
2018-12-19 10:19:07 +00:00
for (name, lines) in namelines.items():
2019-11-19 23:32:58 +00:00
if name and not name[0].isupper():
2020-04-11 14:20:41 +00:00
errors.append(Error(lines[-1], description.format(name=name)))
2018-12-19 10:19:07 +00:00
return Result(candidate.path, errors)
def check_unique_named_task(candidate, settings):
tasks, errors = get_normalized_tasks(candidate, settings)
2020-04-11 14:20:41 +00:00
description = "name '{name}' appears multiple times"
2018-12-19 10:19:07 +00:00
namelines = defaultdict(list)
if not errors:
for task in tasks:
2019-04-04 14:06:18 +00:00
if "name" in task:
namelines[task["name"]].append(task["__line__"])
2018-12-19 10:19:07 +00:00
for (name, lines) in namelines.items():
2019-11-19 23:32:58 +00:00
if name and len(lines) > 1:
2020-04-11 14:20:41 +00:00
errors.append(Error(lines[-1], description.format(name=name)))
2018-12-19 10:19:07 +00:00
return Result(candidate.path, errors)
def check_command_instead_of_module(candidate, settings):
tasks, errors = get_normalized_tasks(candidate, settings)
2019-04-04 14:06:18 +00:00
commands = ["command", "shell", "raw"]
2018-12-19 10:19:07 +00:00
modules = {
2020-04-05 12:33:43 +00:00
"git": "git",
"hg": "hg",
"curl": "get_url or uri",
"wget": "get_url or uri",
"svn": "subversion",
"service": "service",
"mount": "mount",
"rpm": "yum or rpm_key",
"yum": "yum",
"apt-get": "apt-get",
"unzip": "unarchive",
"tar": "unarchive",
"chkconfig": "service",
"rsync": "synchronize",
"supervisorctl": "supervisorctl",
"systemctl": "systemd",
2019-04-04 14:06:18 +00:00
"sed": "template or lineinfile"
2018-12-19 10:19:07 +00:00
}
2020-04-11 14:20:41 +00:00
description = "{exec} command used in place of {module} module"
2018-12-19 10:19:07 +00:00
if not errors:
for task in tasks:
if task["action"]["__ansible_module__"] in commands:
first_cmd_arg = get_first_cmd_arg(task)
2018-12-19 10:19:07 +00:00
executable = os.path.basename(first_cmd_arg)
2020-04-05 12:33:43 +00:00
if (
first_cmd_arg and executable in modules and task["action"].get("warn", True)
and "register" not in task
):
2018-12-19 10:19:07 +00:00
errors.append(
2020-04-11 14:20:41 +00:00
Error(
task["__line__"],
description.format(exec=executable, module=modules[executable])
)
2020-04-05 12:33:43 +00:00
)
2018-12-19 10:19:07 +00:00
return Result(candidate.path, errors)
def check_install_use_latest(candidate, settings):
tasks, errors = get_normalized_tasks(candidate, settings)
2020-04-05 12:33:43 +00:00
package_managers = [
"yum", "apt", "dnf", "homebrew", "pacman", "openbsd_package", "pkg5", "portage", "pkgutil",
"slackpkg", "swdepot", "zypper", "bundler", "pip", "pear", "npm", "yarn", "gem",
"easy_install", "bower", "package", "apk", "openbsd_pkg", "pkgng", "sorcery", "xbps"
]
2018-12-19 10:19:07 +00:00
description = "package installs should use state=present with or without a version"
if not errors:
for task in tasks:
2020-04-05 12:33:43 +00:00
if (
task["action"]["__ansible_module__"] in package_managers
and task["action"].get("state") == "latest"
):
2018-12-19 10:19:07 +00:00
errors.append(Error(task["__line__"], description))
return Result(candidate.path, errors)
def check_shell_instead_command(candidate, settings):
tasks, errors = get_normalized_tasks(candidate, settings)
description = "shell should only be used when piping, redirecting or chaining commands"
if not errors:
for task in tasks:
2019-04-04 14:06:18 +00:00
if task["action"]["__ansible_module__"] == "shell":
# skip processing if args.executable is used as this
# parameter is no longer support by command module
if "executable" in task["action"]:
continue
2019-04-04 14:06:18 +00:00
if "cmd" in task["action"]:
2018-12-19 10:19:07 +00:00
cmd = task["action"].get("cmd", [])
else:
2019-04-04 14:06:18 +00:00
cmd = " ".join(task["action"].get("__ansible_arguments__", []))
2018-12-19 10:19:07 +00:00
unjinja = re.sub(r"\{\{[^\}]*\}\}", "JINJA_VAR", cmd)
2019-04-04 14:06:18 +00:00
if not any([ch in unjinja for ch in "&|<>;$\n*[]{}?"]):
2018-12-19 10:19:07 +00:00
errors.append(Error(task["__line__"], description))
return Result(candidate.path, errors)
def check_command_has_changes(candidate, settings):
tasks, errors = get_normalized_tasks(candidate, settings)
2019-04-04 14:06:18 +00:00
commands = ["command", "shell", "raw"]
2018-12-19 10:19:07 +00:00
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)"
if not errors:
for task in tasks:
if task["action"]["__ansible_module__"] in commands:
2020-04-05 12:33:43 +00:00
if (
"changed_when" not in task and "when" not in task
2020-10-07 21:30:22 +00:00
and "when" not in task.get("__ansible_action_meta__", [])
2020-04-05 12:33:43 +00:00
and "creates" not in task["action"] and "removes" not in task["action"]
):
2018-12-19 10:19:07 +00:00
errors.append(Error(task["__line__"], description))
return Result(candidate.path, errors)
def check_command_instead_of_argument(candidate, settings):
# 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.
tasks, errors = get_normalized_tasks(candidate, settings)
commands = ["command", "shell", "raw"]
arguments = {
'chown': 'owner',
'chmod': 'mode',
'chgrp': 'group',
'ln': 'state=link',
'mkdir': 'state=directory',
'rmdir': 'state=absent',
'rm': 'state=absent'
}
description = "{exec} used in place of file modules argument {arg}"
if not errors:
for task in tasks:
if task["action"]["__ansible_module__"] in commands:
first_cmd_arg = get_first_cmd_arg(task)
executable = os.path.basename(first_cmd_arg)
if (
first_cmd_arg and executable in arguments and task["action"].get("warn", True)
):
errors.append(
Error(
task["__line__"],
description.format(exec=executable, arg=arguments[executable])
)
)
return Result(candidate.path, errors)
2018-12-19 10:19:07 +00:00
def check_empty_string_compare(candidate, settings):
yamllines, errors = get_normalized_yaml(candidate, settings)
2019-04-04 14:06:18 +00:00
description = "use `when: var` rather than `when: var != ""` (or " \
"conversely `when: not var` rather than `when: var == ""`)"
2018-12-19 10:19:07 +00:00
if not errors:
if isinstance(candidate, Template):
matches = []
jinja_string = re.compile("({{|{%)(.*?)(}}|%})")
for i, line in yamllines:
match = jinja_string.findall(line)
if match:
for item in match:
matches.append((i, item[1]))
yamllines = matches
empty_string_compare = re.compile("[=!]= ?[\"'][\"']")
for i, line in yamllines:
2018-12-19 10:19:07 +00:00
if empty_string_compare.findall(line):
errors.append(Error(i, description))
2018-12-19 10:19:07 +00:00
return Result(candidate.path, errors)
def check_compare_to_literal_bool(candidate, settings):
yamllines, errors = get_normalized_yaml(candidate, settings)
description = "use `when: var` rather than `when: var == True` " \
"(or conversely `when: not var`)"
if not errors:
if isinstance(candidate, Template):
matches = []
jinja_string = re.compile("({{|{%)(.*?)(}}|%})")
for i, line in yamllines:
match = jinja_string.findall(line)
if match:
for item in match:
matches.append((i, item[1]))
yamllines = matches
literal_bool_compare = re.compile("[=!]= ?(True|true|False|false)")
2018-12-19 10:19:07 +00:00
for i, line in yamllines:
2018-12-19 10:19:07 +00:00
if literal_bool_compare.findall(line):
errors.append(Error(i, description))
2018-12-19 10:19:07 +00:00
return Result(candidate.path, errors)
def check_delegate_to_localhost(candidate, settings):
tasks, errors = get_normalized_tasks(candidate, settings)
description = "connection: local ensures that unexpected delegated_vars " \
"don't get set (e.g. {{ inventory_hostname }} " \
"used by vars_files)"
if not errors:
for task in tasks:
2019-04-04 14:06:18 +00:00
if task.get("delegate_to") == "localhost":
2018-12-19 10:19:07 +00:00
errors.append(Error(task["__line__"], description))
return Result(candidate.path, errors)
def check_literal_bool_format(candidate, settings):
yamllines, errors = get_normalized_yaml(candidate, settings)
description = "literal bools should be written as 'True/False' or 'yes/no'"
uppercase_bool = re.compile(r"([=!]=|:)\s*(true|false|TRUE|FALSE|Yes|No|YES|NO)\s*$")
if not errors:
for i, line in yamllines:
if uppercase_bool.findall(line):
errors.append(Error(i, description))
return Result(candidate.path, errors)
2019-01-28 11:30:05 +00:00
def check_become_user(candidate, settings):
tasks, errors = get_normalized_tasks(candidate, settings)
description = "the task has 'become:' enabled but 'become_user:' is missing"
2019-04-04 14:06:18 +00:00
true_value = [True, "true", "True", "TRUE", "yes", "Yes", "YES"]
2019-01-28 11:30:05 +00:00
if not errors:
2019-04-04 14:06:18 +00:00
gen = (task for task in tasks if "become" in task)
2019-01-28 11:30:05 +00:00
for task in gen:
2019-04-04 14:06:18 +00:00
if task["become"] in true_value and "become_user" not in task.keys():
2019-01-28 11:30:05 +00:00
errors.append(Error(task["__line__"], description))
return Result(candidate.path, errors)
2019-01-29 23:27:30 +00:00
def check_filter_separation(candidate, settings):
yamllines, errors = get_normalized_yaml(candidate, settings)
description = "no suitable numbers of spaces (required: 1)"
matches = []
braces = re.compile("{{(.*?)}}")
filters = re.compile(r"(?<=\|)([\s]{2,}[^\s}]+|[^\s]+)|([^\s{]+[\s]{2,}|[^\s]+)(?=\|)")
if not errors:
for i, line in yamllines:
match = braces.findall(line)
if match:
for item in match:
matches.append((i, item))
for i, line in matches:
if filters.findall(line):
errors.append(Error(i, description))
return Result(candidate.path, errors)