├── .gitignore ├── .pre-commit-hooks.yaml ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── bin └── ansible-review ├── lib └── ansiblereview │ ├── __init__.py │ ├── __main__.py │ ├── code.py │ ├── examples │ ├── lint-rules │ │ ├── ComparisonToEmptyStringRule.py │ │ ├── ComparisonToLiteralBoolRule.py │ │ ├── DontDelegateToLocalhostRule.py │ │ ├── DontUseLineinfileRule.py │ │ ├── HostIsLocalhostRule.py │ │ ├── HostsFileContainsGroupVarsRule.py │ │ ├── HostsFileContainsHostVarsRule.py │ │ ├── LineTooLongRule.py │ │ ├── MetaMainHasEmptyDependenciesRule.py │ │ ├── MetaMainHasInfoRule.py │ │ ├── NoTabsRule.py │ │ ├── PlaysContainLogicRule.py │ │ └── VariableHasSpacesRule.py │ └── standards.py │ ├── groupvars.py │ ├── inventory.py │ ├── playbook.py │ ├── rolesfile.py │ ├── tasks.py │ ├── utils │ ├── __init__.py │ └── yamlindent.py │ ├── vars.py │ └── version.py ├── setup.cfg ├── setup.py ├── test-deps.txt ├── test ├── TestCreation.py ├── TestDiffEncoding.py ├── TestUtils.py ├── TestYamlReview.py ├── diff.txt ├── inventory │ ├── group_vars │ │ ├── application-prod │ │ ├── application-stage │ │ └── azA │ └── hosts ├── lintrules │ ├── TestTaskFailureRule.py │ └── TestTaskSuccessRule.py ├── standards │ └── standards.py ├── test_cases │ ├── hosts │ ├── test_playbook_0.2.yml │ ├── test_role_unversioned │ │ ├── meta │ │ │ └── main.yml │ │ └── tasks │ │ │ └── main.yml │ ├── test_role_v0.2 │ │ ├── meta │ │ │ └── main.yml │ │ └── tasks │ │ │ └── main.yml │ └── test_role_v0.5 │ │ ├── meta │ │ └── main.yml │ │ └── tasks │ │ └── main.yml ├── yaml_fail.yml └── yaml_success.yml └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__ 3 | *.py[co] 4 | *$py.class 5 | 6 | # Packages 7 | .Python 8 | env/ 9 | build/ 10 | develop-eggs/ 11 | dist/ 12 | downloads/ 13 | eggs/ 14 | .eggs/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Unit test / coverage reports 27 | .tox 28 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # For use with pre-commit. 4 | # See usage instructions at https://pre-commit.com 5 | 6 | - id: ansible-review 7 | name: Ansible-review 8 | description: This hook runs ansible-review. 9 | entry: ansible-review 10 | language: python 11 | files: \.(yaml|yml)$ 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Based on https://github.com/pantsbuild/pex/blob/master/.travis.yml 2 | # 3 | # Enables support for a docker container-based build 4 | # which should provide faster startup times and beefier 5 | # "machines". 6 | # See: http://docs.travis-ci.com/user/workers/container-based-infrastructure/ 7 | sudo: false 8 | 9 | # TRAVIS_PYTHON_VERSION 10 | 11 | matrix: 12 | include: 13 | - language: python 14 | python: "2.7" 15 | env: TOXENV=py27-flake8 16 | 17 | - language: python 18 | python: "2.7" 19 | env: TOXENV=py27-ansible19 20 | 21 | - language: python 22 | python: "2.7" 23 | env: TOXENV=py27-ansible20 24 | 25 | - language: python 26 | python: "2.7" 27 | env: TOXENV=py27-ansible21 28 | 29 | - language: python 30 | python: "2.7" 31 | env: TOXENV=py27-ansible22 32 | 33 | - language: python 34 | python: "2.7" 35 | env: TOXENV=py27-ansible23 36 | 37 | - language: python 38 | python: "2.7" 39 | env: TOXENV=py27-ansible24 40 | 41 | - language: python 42 | python: "2.7" 43 | env: TOXENV=py27-ansible25 44 | 45 | - language: python 46 | python: "2.7" 47 | env: TOXENV=py27-ansibledevel 48 | 49 | - language: python 50 | python: "3.6" 51 | env: TOXENV=py36-ansible22 52 | 53 | - language: python 54 | python: "3.6" 55 | env: TOXENV=py36-ansible23 56 | 57 | - language: python 58 | python: "3.6" 59 | env: TOXENV=py36-ansible24 60 | 61 | - language: python 62 | python: "3.6" 63 | env: TOXENV=py36-ansible25 64 | 65 | - language: python 66 | python: "3.6" 67 | env: TOXENV=py36-ansibledevel 68 | 69 | - language: python 70 | python: "3.6" 71 | env: TOXENV=py36-flake8 72 | 73 | install: 74 | - pip install -r test-deps.txt 75 | 76 | script: 77 | - tox -v 78 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.13.9 2 | 3 | Fix reading from stdin in python 3 4 | 5 | ### 0.13.8 6 | 7 | * Fix missing `get_group_vars` 8 | * Fix encoding issues in tests 9 | 10 | ### 0.13.7 11 | * Add required file for test to zip file 12 | 13 | ### 0.13.6 14 | * Python 3 bug fixes 15 | * Use unicode for git diff 16 | 17 | ### 0.13.5 18 | * Update VariableManager imports for ansible 2.4 19 | 20 | ### 0.13.4 21 | * Import module_loader from ansible.plugins.loader for ansible2.4 22 | 23 | ### 0.13.2 24 | * Move to yaml.safe_load to avoid code execution 25 | 26 | ### 0.13.1 27 | * restructure __main__ and main along pythonic standards 28 | * reintroduce bin/ansible-review for running from source only 29 | 30 | ### 0.13.0 31 | * Ensure that the examples live inside the python package 32 | * Use console_script entry point rather than bin/ansible-review 33 | * Fix some minor rule bugs 34 | * Update to version 0.13.0 35 | 36 | ### 0.12.3 37 | * python3 compatibility 38 | 39 | ### 0.12.2 40 | * ansible-review should respect command line parameters 41 | for lint and standards directories even if config is not 42 | present 43 | 44 | ### 0.12.1 45 | * Don't depend on an RC version of ansible-lint. We rely on 46 | ansible-lint 3.4.1+ because that allows matchplay to be 47 | run against non-playbook files (should probably be renamed!) 48 | 49 | ### 0.12.0 50 | * Ensure inventory scripts are detected as code 51 | * Add `ansible_min_version` declaration 52 | * Call unversioned checks Best Practice rather than Future Standard 53 | * Move `yaml_rolesfile` and `yaml_form_rather_than_key_value` checks 54 | inside ansible-review 55 | * Update standards and add new ansible-lint rules to back them. 56 | * Allow ansible-review to run from example standards and rules by 57 | default 58 | 59 | ### 0.11.1 60 | * Add `importlib` as dependency 61 | 62 | ### 0.11.0 63 | * Make `repeated_vars` actually work 64 | * Create `Makefile` classification 65 | * Enable filtering of standard rules to run with `-s` 66 | 67 | ### 0.10.1 68 | * Fix mis-classification of files with .yml suffix 69 | 70 | ### 0.10.0 71 | * Add check for competing group variables 72 | 73 | ### 0.9.0 74 | * Tidy up log_level 75 | 76 | ### 0.8.2 77 | * Allow configuration file location to be specified 78 | 79 | ### 0.8.0 80 | * Enable required version of ansible-lint to be specified 81 | 82 | ### 0.7.5 83 | * Use `None` for errors that apply to the whole file 84 | 85 | ### 0.7.4 86 | * Allow no rules to contain a version 87 | 88 | ### 0.7.3 89 | * Use release of ansible-lint 3.0.0 90 | 91 | ### 0.7.2 92 | * Fix another indentation false positive 93 | 94 | ### 0.7.1 95 | * Yaml indent fix 96 | 97 | ### 0.7.0 98 | * Split `InventoryVars` into `HostVars` and `GroupVars` 99 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Will Thames and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include test *.py *.yml *.txt hosts 4 | recursive-include lib/ansiblereview/examples *.py 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | ## Using pip 4 | 5 | ``` 6 | pip install ansible-review 7 | ``` 8 | 9 | ## From source 10 | 11 | ``` 12 | # Install dependency https://github.com/willthames/ansible-lint 13 | git clone https://github.com/willthames/ansible-review 14 | export PYTHONPATH=$PYTHONPATH:`pwd`/ansible-review/lib 15 | export PATH=$PATH:`pwd`/ansible-review/bin 16 | ``` 17 | 18 | ## Fedora/RHEL 19 | 20 | ansible-review can be found: under standard Fedora repos, or under [EPEL](http://fedoraproject.org/wiki/EPEL#How_can_I_use_these_extra_packages.3F). 21 | To install ansible-review, use yum or dnf accordingly. 22 | 23 | ``` 24 | yum install ansible-review 25 | ``` 26 | 27 | # Usage 28 | 29 | ``` 30 | ansible-review FILES 31 | ``` 32 | 33 | Where FILES is a space delimited list of files to review. 34 | ansible-review is _not_ recursive and won't descend 35 | into child folders; it just processes the list of files you give it. 36 | 37 | Passing a folder in with the list of files will elicit a warning: 38 | 39 | ``` 40 | WARN: Couldn't classify file ./foldername 41 | ``` 42 | 43 | ansible-review will review inventory files, role 44 | files, python code (modules, plugins) and playbooks. 45 | 46 | * The goal is that each file that changes in a 47 | changeset should be reviewable simply by passing 48 | those files as the arguments to ansible-review. 49 | * Roles are slightly harder, and sub-roles are yet 50 | harder still (currently just using `-R` to process 51 | roles works very well, but doesn't examine the 52 | structure of the role) 53 | * Using `{{ playbook_dir }}` in sub roles is so far 54 | very hard. 55 | * This should work against various repository styles 56 | - per-role repository 57 | - roles with sub-roles 58 | - per-playbook repository 59 | * It should work with roles requirement files and with local roles 60 | 61 | ## Typical approaches 62 | 63 | ### Git repositories 64 | 65 | * `git ls-files | xargs ansible-review` works well in 66 | a roles repo to review the whole role. But it will 67 | review the whole of other repos too. 68 | * `git diff branch_to_compare | ansible-review` will 69 | review only the changes between the branches and 70 | surrounding context. 71 | 72 | ### Without git 73 | 74 | * `find . -type f | xargs ansible-review` will review 75 | all files in the current folder (and all subfolders), 76 | even if they're not checked into git 77 | 78 | # Reviews 79 | 80 | Reviews are nothing without some standards or checklists 81 | against which to review. 82 | 83 | ansible-review comes with a couple of built-in checks, such as 84 | a playbook syntax checker and a hook to ansible-lint. You define your 85 | own standards. 86 | 87 | ## Configuration 88 | 89 | If your standards (and optionally inhouse lint rules) are set up, create 90 | a configuration file in the appropriate location (this will depend on 91 | your operating system) 92 | 93 | The location can be found by using `ansible-review` with no arguments. 94 | 95 | You can override the configuration file location with the `-c` flag. 96 | 97 | ``` 98 | [rules] 99 | lint = /path/to/your/ansible/lint/rules 100 | standards = /path/to/your/standards/rules 101 | ``` 102 | 103 | The standards directory can be overridden with the `-d` argument, 104 | and the lint rules directory can be overwritten with the `-r` argument. 105 | 106 | 107 | ## Standards file 108 | 109 | A standards file comprises a list of standards, and optionally some methods to 110 | check those standards. 111 | 112 | Create a file called standards.py (this can import other modules) 113 | 114 | ``` 115 | from ansiblereview include Standard, Result 116 | 117 | use_modules_instead_of_command = Standard(dict( 118 | name = "Use modules instead of commands", 119 | version = "0.2", 120 | check = ansiblelint('ANSIBLE0005,ANSIBLE0006'), 121 | types = ['playbook', 'task'], 122 | )) 123 | 124 | standards = [ 125 | use_modules_instead_of_command, 126 | packages_should_not_be_latest, 127 | ] 128 | ``` 129 | 130 | When you add new standards, you should increment the version of your standards. 131 | Your playbooks and roles should declare what version of standards you are 132 | using, otherwise ansible-review assumes you're using the latest. The declaration 133 | is done by adding standards version as first line in the file. e.g. 134 | 135 | ``` 136 | # Standards: 1.2 137 | ``` 138 | 139 | To add standards that are advisory, don't set the version. These will cause 140 | a message to be displayed but won't constitute a failure. 141 | 142 | When a standard version is higher than declared version, a message will be 143 | displayed 'WARN: Future standard' and won't constitute a failure. 144 | 145 | An example standards file is available at 146 | [lib/ansiblereview/examples/standards.py](lib/ansiblereview/examples/standards.py) 147 | 148 | If you only want to check one or two standards quickly (perhaps you want 149 | to review your entire code base for deprecated bare words), you can use the 150 | `-s` flag with the name of your standard. You can pass `-s` multiple times. 151 | 152 | ``` 153 | git ls-files | xargs ansible-review -s "bare words are deprecated for with_items" 154 | ``` 155 | 156 | You can see the name of the standards being checked for each different file by running 157 | `ansible-review` with the `-v` option. 158 | 159 | 160 | ## Standards checks 161 | 162 | A typical standards check will look like: 163 | 164 | ``` 165 | def check_playbook_for_something(candidate, settings): 166 | result = Result(candidate.path) # empty result is a success with no output 167 | with open(candidate.path, 'r') as f: 168 | for (lineno, line) in enumerate(f): 169 | if line is dodgy: 170 | # enumerate is 0-based so add 1 to lineno 171 | result.errors.append(Error(lineno+1, "Line is dodgy: reasons")) 172 | return result 173 | ``` 174 | 175 | All standards check take a candidate object, which has a path attribute. 176 | The type can be inferred from the class name (i.e. `type(candidate).__name__`) 177 | 178 | They return a `Result` object, which contains a possibly empty list of `Error` 179 | objects. `Error` objects are formed of a line number and a message. If the 180 | error applies to the whole file being reviewed, set the line number to `None`. 181 | Line numbers are important as `ansible-review` can review just ranges of files 182 | to only review changes (e.g. through piping the output of `git diff` to 183 | `ansible-review`) 184 | 185 | The ansible-lint check is ready out of the box, and just takes a list of 186 | IDs or tags to check. You can point to your own ansible-lint rules 187 | using the configuration file or `-d /path/to/ansible/lint/rules` 188 | 189 | # Pre-commit 190 | 191 | To use ansible-review with [pre-commit](https://pre-commit.com/), just 192 | add the following to your local repo's `.pre-commit-config.yaml` file. 193 | Make sure to change `sha:` to be either a git commit SHA or tag of 194 | ansible-review containing `hooks.yaml`. 195 | 196 | ```yaml 197 | - repo: https://github.com/willthames/ansible-review 198 | sha: bd2e8b6863dc20d8619418e6817d5793c7ebc687 199 | hooks: 200 | - id: ansible-review 201 | ``` 202 | 203 | Notice, that this is currently in testing phase. 204 | -------------------------------------------------------------------------------- /bin/ansible-review: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import ansiblereview.__main__ 5 | 6 | sys.exit(ansiblereview.__main__.main()) 7 | -------------------------------------------------------------------------------- /lib/ansiblereview/__init__.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import default_rulesdir, RulesCollection 2 | import codecs 3 | from functools import partial 4 | import re 5 | import os 6 | from ansiblereview import utils 7 | 8 | try: 9 | # Ansible 2.4 import of module loader 10 | from ansible.plugins.loader import module_loader 11 | except ImportError: 12 | try: 13 | from ansible.plugins import module_loader 14 | except ImportError: 15 | from ansible.utils import module_finder as module_loader 16 | 17 | 18 | class AnsibleReviewFormatter(object): 19 | 20 | def format(self, match): 21 | formatstr = u"{0}:{1}: [{2}] {3} {4}" 22 | return formatstr.format(match.filename, 23 | match.linenumber, 24 | match.rule.id, 25 | match.message, 26 | match.line 27 | ) 28 | 29 | 30 | class Standard(object): 31 | def __init__(self, standard_dict): 32 | self.name = standard_dict.get("name") 33 | self.version = standard_dict.get("version") 34 | self.check = standard_dict.get("check") 35 | self.types = standard_dict.get("types") 36 | 37 | def __repr__(self): 38 | return "Standard: %s (version: %s, types: %s)" % ( 39 | self.name, self.version, self.types) 40 | 41 | 42 | class Error(object): 43 | def __init__(self, lineno, message): 44 | self.lineno = lineno 45 | self.message = message 46 | 47 | def __repr__(self): 48 | if self.lineno: 49 | return "%s: %s" % (self.lineno, self.message) 50 | else: 51 | return self.message 52 | 53 | 54 | class Result(object): 55 | def __init__(self, candidate, errors=None): 56 | self.candidate = candidate 57 | self.errors = errors or [] 58 | 59 | def message(self): 60 | return "\n".join(["{0}:{1}".format(self.candidate, error) 61 | for error in self.errors]) 62 | 63 | 64 | class Candidate(object): 65 | def __init__(self, filename): 66 | self.path = filename 67 | try: 68 | self.version = find_version(filename) 69 | self.binary = False 70 | except UnicodeDecodeError: 71 | self.binary = True 72 | self.filetype = type(self).__name__.lower() 73 | self.expected_version = True 74 | 75 | def review(self, settings, lines=None): 76 | return utils.review(self, settings, lines) 77 | 78 | def __repr__(self): 79 | return "%s (%s)" % (type(self).__name__, self.path) 80 | 81 | def __getitem__(self, item): 82 | return self.__dict__.get(item) 83 | 84 | 85 | class RoleFile(Candidate): 86 | def __init__(self, filename): 87 | super(RoleFile, self).__init__(filename) 88 | self.version = None 89 | parentdir = os.path.dirname(os.path.abspath(filename)) 90 | while parentdir != os.path.dirname(parentdir): 91 | meta_file = os.path.join(parentdir, "meta", "main.yml") 92 | if os.path.exists(meta_file): 93 | self.version = find_version(meta_file) 94 | if self.version: 95 | break 96 | parentdir = os.path.dirname(parentdir) 97 | role_modules = os.path.join(parentdir, 'library') 98 | if os.path.exists(role_modules): 99 | module_loader.add_directory(role_modules) 100 | 101 | 102 | class Playbook(Candidate): 103 | pass 104 | 105 | 106 | class Task(RoleFile): 107 | def __init__(self, filename): 108 | super(Task, self).__init__(filename) 109 | self.filetype = 'tasks' 110 | 111 | 112 | class Handler(RoleFile): 113 | def __init__(self, filename): 114 | super(Handler, self).__init__(filename) 115 | self.filetype = 'handlers' 116 | 117 | 118 | class Vars(Candidate): 119 | pass 120 | 121 | 122 | class Unversioned(Candidate): 123 | def __init__(self, filename): 124 | super(Unversioned, self).__init__(filename) 125 | self.expected_version = False 126 | 127 | 128 | class InventoryVars(Unversioned): 129 | pass 130 | 131 | 132 | class HostVars(InventoryVars): 133 | pass 134 | 135 | 136 | class GroupVars(InventoryVars): 137 | pass 138 | 139 | 140 | class RoleVars(RoleFile): 141 | pass 142 | 143 | 144 | class Meta(RoleFile): 145 | pass 146 | 147 | 148 | class Inventory(Unversioned): 149 | pass 150 | 151 | 152 | class Code(Unversioned): 153 | pass 154 | 155 | 156 | class Template(RoleFile): 157 | pass 158 | 159 | 160 | class Doc(Unversioned): 161 | pass 162 | 163 | 164 | # For ease of checking files for tabs 165 | class Makefile(Unversioned): 166 | pass 167 | 168 | 169 | class File(RoleFile): 170 | pass 171 | 172 | 173 | class Rolesfile(Unversioned): 174 | pass 175 | 176 | 177 | def classify(filename): 178 | parentdir = os.path.basename(os.path.dirname(filename)) 179 | if parentdir in ['tasks']: 180 | return Task(filename) 181 | if parentdir in ['handlers']: 182 | return Handler(filename) 183 | if parentdir in ['vars', 'defaults']: 184 | return RoleVars(filename) 185 | if 'group_vars' in os.path.dirname(filename).split(os.sep): 186 | return GroupVars(filename) 187 | if 'host_vars' in os.path.dirname(filename).split(os.sep): 188 | return HostVars(filename) 189 | if parentdir == 'meta': 190 | return Meta(filename) 191 | if parentdir in ['library', 'lookup_plugins', 'callback_plugins', 192 | 'filter_plugins'] or filename.endswith('.py'): 193 | return Code(filename) 194 | if parentdir in ['inventory']: 195 | return Inventory(filename) 196 | if 'rolesfile' in filename or 'requirements' in filename: 197 | return Rolesfile(filename) 198 | if 'Makefile' in filename: 199 | return Makefile(filename) 200 | if 'templates' in filename.split(os.sep) or filename.endswith('.j2'): 201 | return Template(filename) 202 | if 'files' in filename.split(os.sep): 203 | return File(filename) 204 | if filename.endswith('.yml') or filename.endswith('.yaml'): 205 | return Playbook(filename) 206 | if 'README' in filename: 207 | return Doc(filename) 208 | return None 209 | 210 | 211 | def lintcheck(rulename): 212 | return partial(ansiblelint, rulename) 213 | 214 | 215 | def ansiblelint(rulename, candidate, settings): 216 | result = Result(candidate.path) 217 | rules = RulesCollection() 218 | rules.extend(RulesCollection.create_from_directory(default_rulesdir)) 219 | if settings.lintdir: 220 | rules.extend(RulesCollection.create_from_directory(settings.lintdir)) 221 | 222 | fileinfo = dict(path=candidate.path, type=candidate.filetype) 223 | matches = rules.run(fileinfo, rulename.split(',')) 224 | result.errors = [Error(match.linenumber, "[%s] %s" % (match.rule.id, match.message)) 225 | for match in matches] 226 | return result 227 | 228 | 229 | def find_version(filename, version_regex=r"^# Standards: ([0-9]+\.[0-9]+)"): 230 | version_re = re.compile(version_regex) 231 | with codecs.open(filename, mode='rb', encoding='utf-8') as f: 232 | for line in f: 233 | match = version_re.match(line) 234 | if match: 235 | return match.group(1) 236 | return None 237 | -------------------------------------------------------------------------------- /lib/ansiblereview/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import print_function 4 | import logging 5 | import optparse 6 | import os 7 | import sys 8 | from ansiblereview.version import __version__ 9 | from ansiblereview import classify 10 | from ansiblereview.utils import info, warn, read_config 11 | from appdirs import AppDirs 12 | from pkg_resources import resource_filename 13 | 14 | 15 | def get_candidates_from_diff(difftext): 16 | try: 17 | import unidiff 18 | except ImportError as e: 19 | raise SystemExit("Could not import unidiff library: %s", e.message) 20 | patch = unidiff.PatchSet(difftext, encoding='utf-8') 21 | 22 | candidates = [] 23 | for patchedfile in [patchfile for patchfile in 24 | patch.added_files + patch.modified_files]: 25 | if patchedfile.source_file == '/dev/null': 26 | candidates.append(patchedfile.path) 27 | else: 28 | lines = ",".join(["%s-%s" % (hunk.target_start, hunk.target_start + hunk.target_length) 29 | for hunk in patchedfile]) 30 | candidates.append("%s:%s" % (patchedfile.path, lines)) 31 | return candidates 32 | 33 | 34 | def main(): 35 | config_dir = AppDirs("ansible-review", "com.github.willthames").user_config_dir 36 | default_config_file = os.path.join(config_dir, "config.ini") 37 | 38 | parser = optparse.OptionParser("%prog playbook_file|role_file|inventory_file", 39 | version="%prog " + __version__) 40 | parser.add_option('-c', dest='configfile', default=default_config_file, 41 | help="Location of configuration file: [%s]" % default_config_file) 42 | parser.add_option('-d', dest='rulesdir', 43 | help="Location of standards rules") 44 | parser.add_option('-r', dest='lintdir', 45 | help="Location of additional lint rules") 46 | parser.add_option('-q', dest='log_level', action="store_const", default=logging.WARN, 47 | const=logging.ERROR, help="Only output errors") 48 | parser.add_option('-s', dest='standards_filter', action='append', 49 | help="limit standards to specific names") 50 | parser.add_option('-v', dest='log_level', action="store_const", default=logging.WARN, 51 | const=logging.INFO, help="Show more verbose output") 52 | 53 | options, args = parser.parse_args(sys.argv[1:]) 54 | settings = read_config(options.configfile) 55 | 56 | # Merge CLI options with config options. CLI options override config options. 57 | for key, value in settings.__dict__.items(): 58 | if not getattr(options, key): 59 | setattr(options, key, getattr(settings, key)) 60 | 61 | if os.path.exists(options.configfile): 62 | info("Using configuration file: %s" % options.configfile, options) 63 | else: 64 | warn("No configuration file found at %s" % options.configfile, options, file=sys.stderr) 65 | if not options.rulesdir: 66 | rules_dir = os.path.join(resource_filename('ansiblereview', 'examples')) 67 | warn("Using example standards found at %s" % rules_dir, options, file=sys.stderr) 68 | options.rulesdir = rules_dir 69 | if not options.lintdir: 70 | lint_dir = os.path.join(options.rulesdir, 'lint-rules') 71 | if os.path.exists(lint_dir): 72 | warn("Using example lint-rules found at %s" % lint_dir, options, file=sys.stderr) 73 | options.lintdir = lint_dir 74 | 75 | if len(args) == 0: 76 | buf = sys.stdin 77 | if sys.version_info[0] == 3: 78 | """Bypass bytes to unidiff regardless.""" 79 | buf = buf.buffer 80 | candidates = get_candidates_from_diff(buf) 81 | else: 82 | candidates = args 83 | 84 | errors = 0 85 | for filename in candidates: 86 | if ':' in filename: 87 | (filename, lines) = filename.split(":") 88 | else: 89 | lines = None 90 | candidate = classify(filename) 91 | if candidate: 92 | if candidate.binary: 93 | warn("Not reviewing binary file %s" % filename, options) 94 | continue 95 | if lines: 96 | info("Reviewing %s lines %s" % (candidate, lines), options) 97 | else: 98 | info("Reviewing all of %s" % candidate, options) 99 | errors = errors + candidate.review(options, lines) 100 | else: 101 | info("Couldn't classify file %s" % filename, options) 102 | return errors 103 | -------------------------------------------------------------------------------- /lib/ansiblereview/code.py: -------------------------------------------------------------------------------- 1 | from ansiblereview import Error, Result, utils 2 | 3 | 4 | def code_passes_flake8(candidate, options): 5 | result = utils.execute(["flake8", candidate.path]) 6 | errors = [] 7 | if result.rc: 8 | for line in result.output.strip().split('\n'): 9 | lineno = int(line.split(':')[1]) 10 | errors.append(Error(lineno, line)) 11 | return Result(candidate.path, errors) 12 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/ComparisonToEmptyStringRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | import re 3 | 4 | 5 | class ComparisonToEmptyStringRule(AnsibleLintRule): 6 | id = 'EXTRA0015' 7 | shortdesc = "Don't compare to empty string" 8 | description = 'Use `when: var` rather than `when: var != ""` (or ' \ 9 | 'conversely `when: not var` rather than `when: var == ""`)' 10 | tags = ['idiom'] 11 | empty_string_compare = re.compile("[=!]= ?[\"'][\"']") 12 | 13 | def match(self, file, line): 14 | return self.empty_string_compare.search(line) 15 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/ComparisonToLiteralBoolRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | import re 3 | 4 | 5 | class ComparisonToLiteralBoolRule(AnsibleLintRule): 6 | id = 'EXTRA0014' 7 | shortdesc = "Don't compare to literal True/False" 8 | description = 'Use `when: var` rather than `when: var == True` ' \ 9 | '(or conversely `when: not var`)' 10 | tags = ['idiom'] 11 | literal_bool_compare = re.compile("[=!]= ?(True|true|False|false)") 12 | 13 | def match(self, file, line): 14 | return self.literal_bool_compare.search(line) 15 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/DontDelegateToLocalhostRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | 3 | 4 | class DontDelegateToLocalhostRule(AnsibleLintRule): 5 | id = 'EXTRA0004' 6 | shortdesc = 'Use connection: local rather than delegate_to: localhost' 7 | description = 'Connection: local ensures that unexpected delegated_vars ' \ 8 | "don't get set (e.g. {{ inventory_hostname }} " \ 9 | "used by vars_files)" 10 | tags = ['leastsurprise'] 11 | 12 | def matchtask(self, file, task): 13 | return task.get('delegate_to') == 'localhost' 14 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/DontUseLineinfileRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | 3 | 4 | class DontUseLineinfileRule(AnsibleLintRule): 5 | id = 'EXTRA0002' 6 | shortdesc = 'The lineinfile module is typically nasty' 7 | description = 'While lineinfile supports some idemptotency, using ' \ 8 | 'template or assemble modules to populate configuration ' \ 9 | 'files is preferred' 10 | tags = ['leastsurprise'] 11 | 12 | def matchtask(self, file, task): 13 | return task["action"]["__ansible_module__"] == 'lineinfile' 14 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/HostIsLocalhostRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | 3 | 4 | class HostIsLocalhostRule(AnsibleLintRule): 5 | id = 'EXTRA0007' 6 | shortdesc = 'use connection: local rather than host: localhost' 7 | description = 'Using hosts: localhost limits the quality of ' \ 8 | 'variables available to your playbook' 9 | tags = ['dry'] 10 | 11 | def matchplay(self, file, data): 12 | if data.get('hosts') == 'localhost': 13 | return [({file['type']: data}, self.shortdesc)] 14 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/HostsFileContainsGroupVarsRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | 3 | 4 | class HostsFileContainsGroupVarsRule(AnsibleLintRule): 5 | id = 'EXTRA0009' 6 | shortdesc = 'hosts files should not contain group vars' 7 | description = 'Use inventory group_vars directory rather than ' \ 8 | '[group:vars] in hosts file' 9 | tags = ['inventory'] 10 | 11 | def match(self, file, line): 12 | return line.startswith('[') and line.endswith(':vars]') 13 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/HostsFileContainsHostVarsRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | import re 3 | 4 | 5 | class HostsFileContainsHostVarsRule(AnsibleLintRule): 6 | id = 'EXTRA0010' 7 | shortdesc = 'hosts files should not contain host vars' 8 | description = 'Use inventory host_vars directory rather than ' \ 9 | 'host key=value in hosts file' 10 | tags = ['inventory'] 11 | 12 | regex_host_var = re.compile('([^ ]+)=') 13 | 14 | def match(self, file, line): 15 | match = self.regex_host_var.search(line) 16 | if match: 17 | return any([not group.startswith("ansible_") 18 | for group in match.groups()]) 19 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/LineTooLongRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | 3 | 4 | class LineTooLongRule(AnsibleLintRule): 5 | id = 'EXTRA0006' 6 | shortdesc = 'Lines should be no longer than 100 chars' 7 | description = 'Long lines make code harder to read and ' \ 8 | 'code review more difficult' 9 | tags = ['whitespace'] 10 | 11 | def match(self, file, line): 12 | return len(line) > 100 13 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/MetaMainHasEmptyDependenciesRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | 3 | 4 | class MetaMainHasEmptyDependenciesRule(AnsibleLintRule): 5 | id = 'EXTRA0012' 6 | shortdesc = 'meta/main.yml should not declare dependencies' 7 | description = 'Dependencies hurt the ability to maintain versioned roles' 8 | tags = ['dependencies'] 9 | 10 | def matchplay(self, file, data): 11 | if 'dependencies' not in data or data['dependencies']: 12 | return [({'meta/main.yml': data}, self.shortdesc)] 13 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/MetaMainHasInfoRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | 3 | 4 | class MetaMainHasInfoRule(AnsibleLintRule): 5 | id = 'EXTRA0013' 6 | shortdesc = 'meta/main.yml should contain relevant info' 7 | info = ['author', 'description', 'company', 8 | 'min_ansible_version', 'platforms', 'license'] 9 | description = 'meta/main.yml should contain: ' + ', '.join(info) 10 | tags = ['role'] 11 | 12 | def matchplay(self, file, data): 13 | results = [] 14 | if 'galaxy_info' not in data: 15 | return [({'meta/main.yml': data}, self.description)] 16 | for info in self.info: 17 | if not data['galaxy_info'].get(info, None): 18 | results.append(({'meta/main.yml': data}, 19 | 'role info should contain %s' % info)) 20 | return results 21 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/NoTabsRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | 3 | 4 | class NoTabsRule(AnsibleLintRule): 5 | id = 'EXTRA0005' 6 | shortdesc = 'Most files should not contain tabs' 7 | description = 'Tabs can cause unexpected display issues. Use spaces' 8 | tags = ['whitespace'] 9 | 10 | def match(self, file, line): 11 | return '\t' in line 12 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/PlaysContainLogicRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | 3 | 4 | class PlaysContainLogicRule(AnsibleLintRule): 5 | id = 'EXTRA0008' 6 | shortdesc = 'plays should not contain logic' 7 | description = 'plays should not contain tasks, handlers or vars' 8 | tags = ['dry'] 9 | 10 | def matchplay(self, file, play): 11 | results = [] 12 | for logic in ['tasks', 'pre_tasks', 'post_tasks', 'vars', 'handlers']: 13 | if logic in play and play[logic]: 14 | # we can only access line number of first thing in the section 15 | # so we guess the section starts on the line above. 16 | results.append(({file['type']: play}, 17 | "%s should not be required in a play" % logic)) 18 | return results 19 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/lint-rules/VariableHasSpacesRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | import re 3 | 4 | 5 | class VariableHasSpacesRule(AnsibleLintRule): 6 | id = 'EXTRA0001' 7 | shortdesc = 'Variables should have spaces after {{ and before }}' 8 | description = 'Variables should be of the form {{ varname }}' 9 | tags = ['whitespace', 'templating'] 10 | 11 | bracket_regex = re.compile("{{[^{ ]|[^ }]}}") 12 | 13 | def match(self, file, line): 14 | return self.bracket_regex.search(line) 15 | -------------------------------------------------------------------------------- /lib/ansiblereview/examples/standards.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import yaml 4 | 5 | from ansiblereview import Result, Error, Standard, lintcheck 6 | from ansiblereview.utils.yamlindent import yamlreview 7 | from ansiblereview.inventory import parse, no_vars_in_host_file 8 | from ansiblereview.code import code_passes_flake8 9 | from ansiblereview.vars import repeated_vars 10 | from ansiblereview.playbook import repeated_names 11 | from ansiblereview.rolesfile import yamlrolesfile 12 | from ansiblereview.tasks import yaml_form_rather_than_key_value 13 | from ansiblereview.groupvars import same_variable_defined_in_competing_groups 14 | from ansiblelint.utils import parse_yaml_linenumbers 15 | 16 | 17 | def rolesfile_contains_scm_in_src(candidate, settings): 18 | result = Result(candidate.path) 19 | if candidate.path.endswith(".yml") and os.path.exists(candidate.path): 20 | try: 21 | with codecs.open(candidate.path, mode='rb', encoding='utf-8') as f: 22 | roles = parse_yaml_linenumbers(f.read(), candidate.path) 23 | for role in roles: 24 | if '+' in role.get('src'): 25 | error = Error(role['__line__'], "Use scm key rather " 26 | "than src: scm+url") 27 | result.errors.append(error) 28 | except Exception as e: 29 | result.errors = [Error(None, "Cannot parse YAML from %s: %s" % 30 | (candidate.path, str(e)))] 31 | return result 32 | 33 | 34 | def files_should_have_actual_content(candidate, settings): 35 | errors = [] 36 | with codecs.open(candidate.path, mode='rb', encoding='utf-8') as f: 37 | content = yaml.safe_load(f.read()) 38 | if not content: 39 | errors = [Error(None, "%s appears to have no useful content" % candidate)] 40 | return Result(candidate.path, errors) 41 | 42 | 43 | def host_vars_exist(candidate, settings): 44 | return Result(candidate.path, [Error(None, "Host vars are generally " 45 | "not required")]) 46 | 47 | 48 | def noop(candidate, settings): 49 | return Result(candidate.path) 50 | 51 | 52 | rolesfile_should_be_in_yaml = Standard(dict( 53 | name="Roles file should be in yaml format", 54 | check=yamlrolesfile, 55 | types=["rolesfile"] 56 | )) 57 | 58 | role_must_contain_meta_main = Standard(dict( 59 | name="Roles must contain suitable meta/main.yml", 60 | check=lintcheck('EXTRA0012'), 61 | types=["meta"] 62 | )) 63 | 64 | role_meta_main_must_contain_info = Standard(dict( 65 | name="Roles meta/main.yml must contain important info", 66 | check=lintcheck('EXTRA0013'), 67 | types=["meta"] 68 | )) 69 | 70 | variables_should_contain_whitespace = Standard(dict( 71 | name="Variable uses should contain whitespace", 72 | check=lintcheck('EXTRA0001'), 73 | types=["playbook", "task", "handler", "rolevars", 74 | "hostvars", "groupvars", "template"] 75 | )) 76 | 77 | commands_should_be_idempotent = Standard(dict( 78 | name="Commands should be idempotent", 79 | check=lintcheck('ANSIBLE0012'), 80 | types=["playbook", "task"] 81 | )) 82 | 83 | commands_should_not_be_used_in_place_of_modules = Standard(dict( 84 | name="Commands should not be used in place of modules", 85 | check=lintcheck('ANSIBLE0006,ANSIBLE0007'), 86 | types=["playbook", "task", "handler"] 87 | )) 88 | 89 | package_installs_should_not_use_latest = Standard(dict( 90 | name="Package installs should use present, not latest", 91 | check=lintcheck('ANSIBLE0010'), 92 | types=["playbook", "task", "handler"] 93 | )) 94 | 95 | use_shell_only_when_necessary = Standard(dict( 96 | name="Shell should only be used when essential", 97 | check=lintcheck('ANSIBLE0013'), 98 | types=["playbook", "task", "handler"] 99 | )) 100 | 101 | files_should_be_indented = Standard(dict( 102 | name="YAML should be correctly indented", 103 | check=yamlreview, 104 | types=["playbook", "task", "handler", "rolevars", 105 | "hostvars", "groupvars", "meta"] 106 | )) 107 | 108 | inventory_must_parse = Standard(dict( 109 | name="Inventory must be parseable", 110 | check=parse, 111 | types=["inventory"] 112 | )) 113 | 114 | inventory_hostfiles_should_not_contain_vars = Standard(dict( 115 | name="Inventory host files should not " 116 | "contain variable stanzas ([group:vars])", 117 | check=no_vars_in_host_file, 118 | types=["inventory"] 119 | )) 120 | 121 | code_should_meet_flake8 = Standard(dict( 122 | name="Python code should pass flake8", 123 | check=code_passes_flake8, 124 | types=["code"] 125 | )) 126 | 127 | tasks_are_named = Standard(dict( 128 | name="Tasks and handlers must be named", 129 | check=lintcheck('ANSIBLE0011'), 130 | types=["playbook", "task", "handler"], 131 | )) 132 | 133 | tasks_are_uniquely_named = Standard(dict( 134 | name="Tasks and handlers must be uniquely named within a single file", 135 | check=repeated_names, 136 | types=["playbook", "task", "handler"], 137 | )) 138 | 139 | vars_are_not_repeated_in_same_file = Standard(dict( 140 | name="Vars should only occur once per file", 141 | check=repeated_vars, 142 | types=["rolevars", "hostvars", "groupvars"], 143 | )) 144 | 145 | no_command_line_environment_variables = Standard(dict( 146 | name="Environment variables should be passed through the environment key", 147 | check=lintcheck('ANSIBLE0014'), 148 | types=["playbook", "task", "handler"] 149 | )) 150 | 151 | no_lineinfile = Standard(dict( 152 | name="Lineinfile module should not be used as it suggests " 153 | "more than one thing is managing a file", 154 | check=lintcheck('EXTRA0002'), 155 | types=["playbook", "task", "handler"] 156 | )) 157 | 158 | become_rather_than_sudo = Standard(dict( 159 | name="Use become/become_user/become_method rather than sudo/sudo_user", 160 | check=lintcheck('ANSIBLE0008'), 161 | types=["playbook", "task", "handler"] 162 | )) 163 | 164 | use_yaml_rather_than_key_value = Standard(dict( 165 | name="Use YAML format for tasks and handlers rather than key=value", 166 | check=yaml_form_rather_than_key_value, 167 | types=["playbook", "task", "handler"] 168 | )) 169 | 170 | roles_scm_not_in_src = Standard(dict( 171 | name="Use scm key rather than src: scm+url", 172 | check=rolesfile_contains_scm_in_src, 173 | types=["rolesfile"] 174 | )) 175 | 176 | files_should_not_be_purposeless = Standard(dict( 177 | name="Files should contain useful content", 178 | check=files_should_have_actual_content, 179 | types=["playbook", "task", "handler", "rolevars", "defaults", "meta"] 180 | )) 181 | 182 | playbooks_should_not_contain_logic = Standard(dict( 183 | name="Playbooks should not contain logic (vars, tasks, handlers)", 184 | check=lintcheck('EXTRA0008'), 185 | types=["playbook"] 186 | )) 187 | 188 | host_vars_should_not_be_present = Standard(dict( 189 | name="Host vars should not be present", 190 | check=host_vars_exist, 191 | types=["hostvars"] 192 | )) 193 | 194 | with_items_bare_words = Standard(dict( 195 | name="bare words are deprecated for with_items", 196 | check=lintcheck('ANSIBLE0015'), 197 | types=["task", "handler", "playbook"], 198 | version="0.0" 199 | )) 200 | 201 | file_permissions_are_octal = Standard(dict( 202 | name="octal file permissions should start with a leading zero", 203 | check=lintcheck('ANSIBLE0009'), 204 | types=["task", "handler", "playbook"] 205 | )) 206 | 207 | inventory_hostsfile_has_group_vars = Standard(dict( 208 | name="inventory file should not contain group variables", 209 | check=lintcheck('EXTRA0009'), 210 | types=["inventory"] 211 | )) 212 | 213 | inventory_hostsfile_has_host_vars = Standard(dict( 214 | name="inventory file should not contain host variables " 215 | "(except e.g. ansible_host, ansible_user, etc.)", 216 | check=lintcheck('EXTRA0010'), 217 | types=["inventory"] 218 | )) 219 | 220 | test_matching_groupvar = Standard(dict( 221 | check=same_variable_defined_in_competing_groups, 222 | name="Same variable defined in siblings", 223 | types=["groupvars"] 224 | )) 225 | 226 | hosts_should_not_be_localhost = Standard(dict( 227 | check=lintcheck('EXTRA0007'), 228 | name="Use connection: local rather than hosts: localhost", 229 | types=["playbook"] 230 | )) 231 | 232 | # tasks_should_not_use_action = 233 | 234 | use_handlers_rather_than_when_changed = Standard(dict( 235 | check=lintcheck('ANSIBLE0016'), 236 | name="Use handlers rather than when: changed in tasks", 237 | types=['task', 'playbook'] 238 | )) 239 | 240 | most_files_shouldnt_have_tabs = Standard(dict( 241 | check=lintcheck('EXTRA0005'), 242 | name="Don't use tabs in almost anything that isn't a Makefile", 243 | types=["playbook", "task", "handler", "rolevars", "defaults", "meta", 244 | "code", "groupvars", "hostvars", "inventory", "doc", "template", 245 | "file"] 246 | )) 247 | 248 | dont_delegate_to_localhost = Standard(dict( 249 | check=lintcheck('EXTRA0004'), 250 | name="Use connection: local rather than delegate_to: localhost", 251 | types=["playbook", "task", "handler"] 252 | )) 253 | 254 | become_user_should_have_become = Standard(dict( 255 | check=lintcheck('ANSIBLE0017'), 256 | name="become_user should be accompanied by become", 257 | types=["playbook", "task", "handler"] 258 | )) 259 | 260 | dont_compare_to_literal_bool = Standard(dict( 261 | check=lintcheck('EXTRA0014'), 262 | name="Don't compare to True or False - use `when: var` or `when: not var`", 263 | types=["playbook", "task", "handler", "template"] 264 | )) 265 | 266 | dont_compare_to_empty_string = Standard(dict( 267 | check=lintcheck('EXTRA0015'), 268 | name="Don't compare to \"\" - use `when: var` or `when: not var`", 269 | types=["playbook", "task", "handler", "template"] 270 | )) 271 | 272 | # Update this every time standards version increase 273 | latest_version = Standard(dict( 274 | check=noop, 275 | name="No-op check to ensure latest standards version is set", 276 | version="0.1", 277 | types=[] 278 | )) 279 | 280 | ansible_min_version = '2.1' 281 | ansible_review_min_version = '0.12.0' 282 | ansible_lint_min_version = '3.4.0' 283 | 284 | standards = [ 285 | rolesfile_should_be_in_yaml, 286 | role_must_contain_meta_main, 287 | role_meta_main_must_contain_info, 288 | become_rather_than_sudo, 289 | variables_should_contain_whitespace, 290 | commands_should_be_idempotent, 291 | commands_should_not_be_used_in_place_of_modules, 292 | package_installs_should_not_use_latest, 293 | files_should_be_indented, 294 | use_shell_only_when_necessary, 295 | inventory_must_parse, 296 | inventory_hostfiles_should_not_contain_vars, 297 | code_should_meet_flake8, 298 | tasks_are_named, 299 | tasks_are_uniquely_named, 300 | vars_are_not_repeated_in_same_file, 301 | no_command_line_environment_variables, 302 | no_lineinfile, 303 | use_yaml_rather_than_key_value, 304 | roles_scm_not_in_src, 305 | files_should_not_be_purposeless, 306 | playbooks_should_not_contain_logic, 307 | host_vars_should_not_be_present, 308 | with_items_bare_words, 309 | file_permissions_are_octal, 310 | inventory_hostsfile_has_host_vars, 311 | inventory_hostsfile_has_group_vars, 312 | test_matching_groupvar, 313 | hosts_should_not_be_localhost, 314 | dont_delegate_to_localhost, 315 | most_files_shouldnt_have_tabs, 316 | use_handlers_rather_than_when_changed, 317 | become_user_should_have_become, 318 | dont_compare_to_empty_string, 319 | dont_compare_to_literal_bool, 320 | latest_version, 321 | ] 322 | -------------------------------------------------------------------------------- /lib/ansiblereview/groupvars.py: -------------------------------------------------------------------------------- 1 | import ansible.inventory 2 | from ansiblereview import Result, Error 3 | from ansible.errors import AnsibleError 4 | import inspect 5 | import os 6 | 7 | try: 8 | import ansible.parsing.dataloader 9 | from ansible.vars.manager import VariableManager 10 | ANSIBLE = 2 11 | except ImportError: 12 | try: 13 | from ansible.vars.manager import VariableManager 14 | ANSIBLE = 2 15 | except ImportError: 16 | ANSIBLE = 1 17 | 18 | 19 | _vars = dict() 20 | _inv = None 21 | 22 | 23 | def get_group_vars(group, inventory): 24 | try: 25 | from ansible.inventory.helpers import get_group_vars 26 | return get_group_vars(inventory.groups.values()) 27 | except ImportError: 28 | pass 29 | # http://stackoverflow.com/a/197053 30 | vars = inspect.getargspec(inventory.get_group_vars) 31 | if 'return_results' in vars[0]: 32 | return inventory.get_group_vars(group, return_results=True) 33 | else: 34 | return inventory.get_group_vars(group) 35 | 36 | 37 | def remove_inherited_and_overridden_vars(vars, group, inventory): 38 | if group not in _vars: 39 | _vars[group] = get_group_vars(group, inventory) 40 | gv = _vars[group] 41 | for (k, v) in vars.items(): 42 | if k in gv: 43 | if gv[k] == v: 44 | vars.pop(k) 45 | else: 46 | gv.pop(k) 47 | 48 | 49 | def remove_inherited_and_overridden_group_vars(group, inventory): 50 | if group not in _vars: 51 | _vars[group] = get_group_vars(group, inventory) 52 | for ancestor in group.get_ancestors(): 53 | remove_inherited_and_overridden_vars(_vars[group], ancestor, inventory) 54 | 55 | 56 | def same_variable_defined_in_competing_groups(candidate, options): 57 | result = Result(candidate.path) 58 | # assume that group_vars file is under an inventory *directory* 59 | invfile = os.path.dirname(os.path.dirname(candidate.path)) 60 | global _inv 61 | 62 | try: 63 | if ANSIBLE > 1: 64 | loader = ansible.parsing.dataloader.DataLoader() 65 | try: 66 | from ansible.inventory.manager import InventoryManager 67 | inv = _inv or InventoryManager(loader=loader, sources=invfile) 68 | except ImportError: 69 | var_manager = VariableManager() 70 | inv = _inv or ansible.inventory.Inventory(loader=loader, 71 | variable_manager=var_manager, 72 | host_list=invfile) 73 | _inv = inv 74 | else: 75 | inv = _inv or ansible.inventory.Inventory(invfile) 76 | _inv = inv 77 | except AnsibleError as e: 78 | result.errors = [Error(None, "Inventory is broken: %s" % e.message)] 79 | return result 80 | 81 | if hasattr(inv, 'groups'): 82 | group = inv.groups.get(os.path.basename(candidate.path)) 83 | else: 84 | group = inv.get_group(os.path.basename(candidate.path)) 85 | if not group: 86 | # group file exists in group_vars but no related group 87 | # in inventory directory 88 | return result 89 | remove_inherited_and_overridden_group_vars(group, inv) 90 | group_vars = set(_vars[group].keys()) 91 | child_hosts = group.hosts 92 | child_groups = group.child_groups 93 | siblings = set() 94 | 95 | for child_host in child_hosts: 96 | siblings.update(child_host.groups) 97 | for child_group in child_groups: 98 | siblings.update(child_group.parent_groups) 99 | for sibling in siblings: 100 | if sibling != group: 101 | remove_inherited_and_overridden_group_vars(sibling, inv) 102 | sibling_vars = set(_vars[sibling].keys()) 103 | common_vars = sibling_vars & group_vars 104 | common_hosts = [host.name for host in set(child_hosts) & set(sibling.hosts)] 105 | if common_vars and common_hosts: 106 | for var in common_vars: 107 | error_msg_template = "Sibling groups {0} and {1} with common hosts {2} " + \ 108 | "both define variable {3}" 109 | error_msg = error_msg_template.format(group.name, sibling.name, 110 | ", ".join(common_hosts), var) 111 | result.errors.append(Error(None, error_msg)) 112 | 113 | return result 114 | -------------------------------------------------------------------------------- /lib/ansiblereview/inventory.py: -------------------------------------------------------------------------------- 1 | import ansible.inventory 2 | from ansiblereview import Result, Error 3 | import codecs 4 | import yaml 5 | 6 | try: 7 | import ansible.parsing.dataloader 8 | from ansible.vars.manager import VariableManager 9 | ANSIBLE = 2 10 | except ImportError: 11 | try: 12 | from ansible.vars import VariableManager 13 | ANSIBLE = 2 14 | except ImportError: 15 | ANSIBLE = 1 16 | 17 | 18 | def no_vars_in_host_file(candidate, options): 19 | errors = [] 20 | with codecs.open(candidate.path, mode='rb', encoding='utf-8') as f: 21 | try: 22 | yaml.safe_load(f) 23 | except Exception: 24 | for (lineno, line) in enumerate(f): 25 | if ':vars]' in line: 26 | errors.append(Error(lineno + 1, "contains a vars definition")) 27 | return Result(candidate.path, errors) 28 | 29 | 30 | def parse(candidate, options): 31 | result = Result(candidate.path) 32 | try: 33 | if ANSIBLE > 1: 34 | loader = ansible.parsing.dataloader.DataLoader() 35 | var_manager = VariableManager() 36 | ansible.inventory.Inventory(loader=loader, variable_manager=var_manager, 37 | host_list=candidate.path) 38 | else: 39 | ansible.inventory.Inventory(candidate.path) 40 | except Exception as e: 41 | result.errors = [Error(None, "Inventory is broken: %s" % e.message)] 42 | return result 43 | -------------------------------------------------------------------------------- /lib/ansiblereview/playbook.py: -------------------------------------------------------------------------------- 1 | from ansiblereview import utils, Playbook, Result, Error 2 | from ansiblelint.utils import parse_yaml_linenumbers, get_action_tasks 3 | import codecs 4 | from collections import defaultdict 5 | import os 6 | 7 | 8 | def install_roles(playbook, settings): 9 | rolesdir = os.path.join(os.path.dirname(playbook), "roles") 10 | rolesfile = os.path.join(os.path.dirname(playbook), "rolesfile.yml") 11 | if not os.path.exists(rolesfile): 12 | rolesfile = os.path.join(os.path.dirname(playbook), "rolesfile") 13 | if os.path.exists(rolesfile): 14 | utils.info("Installing roles: Using rolesfile %s and roles dir %s" % (rolesfile, rolesdir), 15 | settings) 16 | result = utils.execute(["ansible-galaxy", "install", "-r", rolesfile, "-p", rolesdir]) 17 | if result.rc: 18 | utils.error("Could not install roles from %s:\n%s" % 19 | (rolesdir, result.output)) 20 | else: 21 | utils.info(u"Roles installed \u2713", settings) 22 | else: 23 | utils.warn("No roles file found for playbook %s, tried %s and %s.yml" % 24 | (playbook, rolesfile, rolesfile), settings) 25 | 26 | 27 | def syntax_check(playbook, settings): 28 | result = utils.execute(["ansible-playbook", "--syntax-check", playbook]) 29 | if result.rc: 30 | message = "FATAL: Playbook syntax check failed for %s:\n%s" % \ 31 | (playbook, result.output) 32 | utils.abort(message) 33 | else: 34 | utils.info("Playbook syntax check succeeded for %s" % playbook, settings) 35 | 36 | 37 | def review(playbook, settings): 38 | install_roles(playbook, settings) 39 | syntax_check(playbook, settings) 40 | return utils.review(Playbook(playbook), settings) 41 | 42 | 43 | def repeated_names(playbook, settings): 44 | with codecs.open(playbook['path'], mode='rb', encoding='utf-8') as f: 45 | yaml = parse_yaml_linenumbers(f, playbook['path']) 46 | namelines = defaultdict(list) 47 | errors = [] 48 | if yaml: 49 | for task in get_action_tasks(yaml, playbook): 50 | if 'name' in task: 51 | namelines[task['name']].append(task['__line__']) 52 | for (name, lines) in namelines.items(): 53 | if len(lines) > 1: 54 | errors.append(Error(lines[-1], 55 | "Task/handler name %s appears multiple times" % name)) 56 | return Result(playbook, errors) 57 | -------------------------------------------------------------------------------- /lib/ansiblereview/rolesfile.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import yaml 4 | 5 | from ansiblereview import Result, Error 6 | 7 | 8 | def yamlrolesfile(candidate, settings): 9 | rolesfile = os.path.join(os.path.dirname(candidate.path), "rolesfile") 10 | result = Result(candidate) 11 | if os.path.exists(rolesfile) and not os.path.exists(rolesfile + ".yml"): 12 | result.errors = [Error(None, "Rolesfile %s does not " 13 | "have a .yml extension" % rolesfile)] 14 | return result 15 | rolesfile = os.path.join(os.path.dirname(candidate.path), "rolesfile.yml") 16 | if os.path.exists(rolesfile): 17 | with codecs.open(rolesfile, mode='rb', encoding='utf-8') as f: 18 | try: 19 | yaml.safe_load(f) 20 | except Exception as e: 21 | result.errors = [Error(None, "Cannot parse YAML from %s: %s" % 22 | (rolesfile, str(e)))] 23 | return result 24 | -------------------------------------------------------------------------------- /lib/ansiblereview/tasks.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | from ansiblereview import Result, Error 3 | from ansiblelint.utils import get_action_tasks, normalize_task, \ 4 | parse_yaml_linenumbers 5 | 6 | 7 | def yaml_form_rather_than_key_value(candidate, settings): 8 | with codecs.open(candidate.path, mode='rb', encoding='utf-8') as f: 9 | content = parse_yaml_linenumbers(f.read(), candidate.path) 10 | errors = [] 11 | if content: 12 | fileinfo = dict(type=candidate.filetype, path=candidate.path) 13 | for task in get_action_tasks(content, fileinfo): 14 | normal_form = normalize_task(task, candidate.path) 15 | action = normal_form['action']['__ansible_module__'] 16 | arguments = normal_form['action']['__ansible_arguments__'] 17 | # Cope with `set_fact` where task['set_fact'] is None 18 | if not task.get(action): 19 | continue 20 | if isinstance(task[action], dict): 21 | continue 22 | # strip additional newlines off task[action] 23 | if task[action].strip().split() != arguments: 24 | errors.append(Error(task['__line__'], "Task arguments appear " 25 | "to be in key value rather " 26 | "than YAML format")) 27 | return Result(candidate.path, errors) 28 | -------------------------------------------------------------------------------- /lib/ansiblereview/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | try: 4 | from ansible.utils.color import stringc 5 | except ImportError: 6 | from ansible.color import stringc 7 | import ansiblereview 8 | import ansible 9 | from ansiblereview.version import __version__ 10 | import ansiblelint.version 11 | from distutils.version import LooseVersion 12 | import importlib 13 | import logging 14 | import os 15 | import subprocess 16 | import sys 17 | 18 | try: 19 | import ConfigParser as configparser 20 | except ImportError: 21 | import configparser 22 | 23 | 24 | def abort(message, file=sys.stderr): 25 | print(stringc("FATAL: %s" % message, 'red'), file=file) 26 | sys.exit(1) 27 | 28 | 29 | def error(message, file=sys.stderr): 30 | print(stringc("ERROR: %s" % message, 'red'), file=file) 31 | 32 | 33 | def warn(message, settings, file=sys.stdout): 34 | if settings.log_level <= logging.WARNING: 35 | print(stringc("WARN: %s" % message, 'yellow'), file=file) 36 | 37 | 38 | def info(message, settings, file=sys.stdout): 39 | if settings.log_level <= logging.INFO: 40 | print(stringc("INFO: %s" % message, 'green'), file=file) 41 | 42 | 43 | def standards_latest(standards): 44 | return max([standard.version for standard in standards if standard.version] or ["0.1"], 45 | key=LooseVersion) 46 | 47 | 48 | def lines_ranges(lines_spec): 49 | if not lines_spec: 50 | return None 51 | result = [] 52 | for interval in lines_spec.split(","): 53 | (start, end) = interval.split("-") 54 | result.append(range(int(start), int(end)+1)) 55 | return result 56 | 57 | 58 | def is_line_in_ranges(line, ranges): 59 | return not ranges or any([line in r for r in ranges]) 60 | 61 | 62 | def read_standards(settings): 63 | if not settings.rulesdir: 64 | abort("Standards directory is not set on command line or in configuration file - aborting") 65 | sys.path.append(os.path.abspath(os.path.expanduser(settings.rulesdir))) 66 | try: 67 | standards = importlib.import_module('standards') 68 | except ImportError as e: 69 | abort("Could not import standards from directory %s: %s" % (settings.rulesdir, str(e))) 70 | return standards 71 | 72 | 73 | def review(candidate, settings, lines=None): 74 | errors = 0 75 | 76 | standards = read_standards(settings) 77 | if getattr(standards, 'ansible_min_version', None) and \ 78 | LooseVersion(standards.ansible_min_version) > LooseVersion(ansible.__version__): 79 | raise SystemExit("Standards require ansible version %s (current version %s). " 80 | "Please upgrade ansible." % 81 | (standards.ansible_min_version, ansible.__version__)) 82 | 83 | if getattr(standards, 'ansible_review_min_version', None) and \ 84 | LooseVersion(standards.ansible_review_min_version) > LooseVersion(__version__): 85 | raise SystemExit("Standards require ansible-review version %s (current version %s). " 86 | "Please upgrade ansible-review." % 87 | (standards.ansible_review_min_version, __version__)) 88 | 89 | if getattr(standards, 'ansible_lint_min_version', None) and \ 90 | LooseVersion(standards.ansible_lint_min_version) > \ 91 | LooseVersion(ansiblelint.version.__version__): 92 | raise SystemExit("Standards require ansible-lint version %s (current version %s). " 93 | "Please upgrade ansible-lint." % 94 | (standards.ansible_lint_min_version, ansiblelint.version.__version__)) 95 | 96 | if not candidate.version: 97 | candidate.version = standards_latest(standards.standards) 98 | if candidate.expected_version: 99 | if isinstance(candidate, ansiblereview.RoleFile): 100 | warn("%s %s is in a role that contains a meta/main.yml without a declared " 101 | "standards version. " 102 | "Using latest standards version %s" % 103 | (type(candidate).__name__, candidate.path, candidate.version), 104 | settings) 105 | else: 106 | warn("%s %s does not present standards version. " 107 | "Using latest standards version %s" % 108 | (type(candidate).__name__, candidate.path, candidate.version), 109 | settings) 110 | 111 | info("%s %s declares standards version %s" % 112 | (type(candidate).__name__, candidate.path, candidate.version), 113 | settings) 114 | 115 | for standard in standards.standards: 116 | if type(candidate).__name__.lower() not in standard.types: 117 | continue 118 | if settings.standards_filter and standard.name not in settings.standards_filter: 119 | continue 120 | result = standard.check(candidate, settings) 121 | for err in [err for err in result.errors 122 | if not err.lineno or 123 | is_line_in_ranges(err.lineno, lines_ranges(lines))]: 124 | if not standard.version: 125 | warn("Best practice \"%s\" not met:\n%s:%s" % 126 | (standard.name, candidate.path, err), settings) 127 | elif LooseVersion(standard.version) > LooseVersion(candidate.version): 128 | warn("Future standard \"%s\" not met:\n%s:%s" % 129 | (standard.name, candidate.path, err), settings) 130 | else: 131 | error("Standard \"%s\" not met:\n%s:%s" % 132 | (standard.name, candidate.path, err)) 133 | errors = errors + 1 134 | if not result.errors: 135 | if not standard.version: 136 | info("Best practice \"%s\" met" % standard.name, settings) 137 | elif LooseVersion(standard.version) > LooseVersion(candidate.version): 138 | info("Future standard \"%s\" met" % standard.name, settings) 139 | else: 140 | info("Standard \"%s\" met" % standard.name, settings) 141 | 142 | return errors 143 | 144 | 145 | class Settings(object): 146 | def __init__(self, values): 147 | self.rulesdir = values.get('rulesdir') 148 | self.lintdir = values.get('lintdir') 149 | self.configfile = values.get('configfile') 150 | 151 | 152 | def read_config(config_file): 153 | config = configparser.RawConfigParser({'standards': None, 'lint': None}) 154 | config.read(config_file) 155 | 156 | if config.has_section('rules'): 157 | return Settings(dict(rulesdir=config.get('rules', 'standards'), 158 | lintdir=config.get('rules', 'lint'), 159 | configfile=config_file)) 160 | else: 161 | return Settings(dict(rulesdir=None, lintdir=None, configfile=config_file)) 162 | 163 | 164 | class ExecuteResult(object): 165 | pass 166 | 167 | 168 | def execute(cmd): 169 | result = ExecuteResult() 170 | encoding = 'UTF-8' 171 | env = dict(os.environ) 172 | env['PYTHONIOENCODING'] = encoding 173 | proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, 174 | stderr=subprocess.STDOUT, env=env) 175 | result.output = proc.communicate()[0].decode(encoding) 176 | result.rc = proc.returncode 177 | return result 178 | -------------------------------------------------------------------------------- /lib/ansiblereview/utils/yamlindent.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Quick and dirty YAML checker. 5 | Verifies that lines only increase indentation by 2 6 | and that lines starting '- ' don't have additional 7 | indentation. 8 | Blank lines are ignored. 9 | 10 | GOOD: 11 | 12 | ``` 13 | - tasks: 14 | - name: hello world 15 | command: echo hello 16 | 17 | - name: another task 18 | debug: 19 | msg: hello 20 | ``` 21 | 22 | BAD: 23 | 24 | ``` 25 | - tasks: 26 | # comment in random indentation 27 | - name: hello world 28 | debug: 29 | msg: hello 30 | ``` 31 | """ 32 | 33 | 34 | from __future__ import print_function 35 | import codecs 36 | import re 37 | import sys 38 | from ansiblereview import Result, Error, utils 39 | 40 | 41 | def indent_checker(filename): 42 | with codecs.open(filename, mode='rb', encoding='utf-8') as f: 43 | indent_regex = re.compile(r"^(?P\s*(?:- )?)(?P.*)$") 44 | lineno = 0 45 | prev_indent = '' 46 | errors = [] 47 | for line in f: 48 | lineno += 1 49 | match = indent_regex.match(line) 50 | if len(match.group('rest')) == 0: 51 | continue 52 | curr_indent = match.group('indent') 53 | offset = len(curr_indent) - len(prev_indent) 54 | if offset > 0 and offset != 2: 55 | if match.group('indent').endswith('- '): 56 | errors.append(Error(lineno, "lines starting with '- ' should have same " 57 | "or less indentation than previous line")) 58 | else: 59 | errors.append(Error(lineno, "indentation should increase by 2 chars")) 60 | prev_indent = curr_indent 61 | return errors 62 | 63 | 64 | def yamlreview(candidate, settings): 65 | errors = indent_checker(candidate.path) 66 | return Result(candidate.path, errors) 67 | 68 | 69 | if __name__ == '__main__': 70 | args = sys.argv[1:] or [sys.stdin] 71 | rc = 0 72 | for arg in args: 73 | result = yamlreview(arg, utils.Settings()) 74 | for error in result.errors(): 75 | print("ERROR: %s:%s: %s" % (arg, error.lineno, error.message), file=sys.stderr) 76 | rc = 1 77 | sys.exit(rc) 78 | -------------------------------------------------------------------------------- /lib/ansiblereview/vars.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import yaml 3 | from yaml.composer import Composer 4 | from ansiblereview import Result, Error 5 | 6 | 7 | def hunt_repeated_yaml_keys(data): 8 | """Parses yaml and returns a list of repeated variables and 9 | the line on which they occur 10 | """ 11 | loader = yaml.Loader(data) 12 | 13 | def compose_node(parent, index): 14 | # the line number where the previous token has ended (plus empty lines) 15 | line = loader.line 16 | node = Composer.compose_node(loader, parent, index) 17 | node.__line__ = line + 1 18 | return node 19 | 20 | def construct_mapping(node, deep=False): 21 | mapping = dict() 22 | errors = dict() 23 | for key_node, value_node in node.value: 24 | key = key_node.value 25 | if key in mapping: 26 | if key in errors: 27 | errors[key].append(key_node.__line__) 28 | else: 29 | errors[key] = [mapping[key], key_node.__line__] 30 | 31 | mapping[key] = key_node.__line__ 32 | 33 | return errors 34 | 35 | loader.compose_node = compose_node 36 | loader.construct_mapping = construct_mapping 37 | data = loader.get_single_data() 38 | return data 39 | 40 | 41 | def repeated_vars(candidate, settings): 42 | with codecs.open(candidate.path, 'r') as f: 43 | errors = hunt_repeated_yaml_keys(f) or dict() 44 | return Result(candidate, [Error(err_line, "Variable %s occurs more than once" % err_key) 45 | for err_key in errors for err_line in errors[err_key]]) 46 | -------------------------------------------------------------------------------- /lib/ansiblereview/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.13.9' 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [flake8] 5 | max-line-length = 100 6 | exclude = .git,.hg,.svn,test,setup.py,__pycache__ 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import find_packages 3 | from setuptools import setup 4 | import sys 5 | 6 | 7 | sys.path.insert(0, os.path.abspath('lib')) 8 | 9 | exec(open('lib/ansiblereview/version.py').read()) 10 | 11 | setup( 12 | name='ansible-review', 13 | version=__version__, 14 | description=('reviews ansible playbooks, roles and inventory and suggests improvements'), 15 | keywords='ansible, code review', 16 | author='Will Thames', 17 | author_email='will@thames.id.au', 18 | url='https://github.com/willthames/ansible-review', 19 | package_dir={'': 'lib'}, 20 | packages=find_packages('lib'), 21 | include_package_data=True, 22 | zip_safe=False, 23 | install_requires=['ansible-lint>=3.4.1', 'pyyaml', 'appdirs', 'unidiff', 'flake8'], 24 | entry_points={ 25 | 'console_scripts': [ 26 | 'ansible-review = ansiblereview.__main__:main' 27 | ] 28 | }, 29 | classifiers=[ 30 | 'License :: OSI Approved :: MIT License', 31 | ], 32 | test_suite="test" 33 | ) 34 | -------------------------------------------------------------------------------- /test-deps.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | mock 3 | nose 4 | pep8-naming 5 | tox 6 | wheel 7 | -------------------------------------------------------------------------------- /test/TestCreation.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2014 Will Thames 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import os 22 | import unittest 23 | 24 | from ansiblereview import Playbook, Inventory 25 | import ansiblereview.code as code 26 | 27 | 28 | class TestCreation(unittest.TestCase): 29 | def setUp(self): 30 | self.cwd = os.path.dirname(__file__) 31 | 32 | def test_playbook_expected_version(self): 33 | candidate = Playbook(os.path.join(self.cwd, 'test_cases', 'test_playbook_0.2.yml')) 34 | self.assertTrue(candidate.expected_version) 35 | 36 | def test_inventory_expected_version(self): 37 | candidate = Inventory(os.path.join(self.cwd, 'test_cases', 'hosts')) 38 | self.assertFalse(candidate.expected_version) 39 | -------------------------------------------------------------------------------- /test/TestDiffEncoding.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import sys 4 | import io 5 | import mock 6 | import ansiblereview.__main__ as main 7 | 8 | 9 | def patch_stdin_with(file_name): 10 | def decorator(func): 11 | def stdin_patcher(*args, **kwargs): 12 | with io.open(file_name, 'rb') as f: 13 | mock_stream = ( 14 | io.TextIOWrapper if sys.version_info[0] == 3 15 | else io.BufferedReader 16 | )(f) 17 | 18 | with mock.patch.object(sys, 'stdin', mock_stream): 19 | return func(*args, **kwargs) 20 | return stdin_patcher 21 | return decorator 22 | 23 | 24 | class TestDiffEncoding(unittest.TestCase): 25 | 26 | directory = os.path.dirname(__file__) 27 | 28 | def test_diff_encoding(self): 29 | difflines = [] 30 | with io.open(os.path.join(self.directory, 'diff.txt'), 'r', encoding='utf-8') as f: 31 | for line in f.readlines(): 32 | encodedline = line.encode("utf-8") 33 | difflines.append(encodedline) 34 | candidate = main.get_candidates_from_diff(difflines) 35 | self.assertEqual(len(candidate), 1) 36 | 37 | @mock.patch.object(sys, 'argv', [sys.argv[0]]) # Enter stdin read mode 38 | @patch_stdin_with(os.path.join(directory, 'diff.txt')) 39 | def test_diff_stdin_encoding(self): 40 | errors_num = main.main() 41 | assert errors_num == 0 42 | -------------------------------------------------------------------------------- /test/TestUtils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2014 Will Thames 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import os 22 | import unittest 23 | 24 | from ansiblereview import Playbook, Task, Code 25 | import ansiblereview.code as code 26 | 27 | 28 | class TestUtils(unittest.TestCase): 29 | def setUp(self): 30 | self.cwd = os.path.dirname(__file__) 31 | 32 | def test_find_version_playbook(self): 33 | candidate = Playbook(os.path.join(self.cwd, 'test_cases', 'test_playbook_0.2.yml')) 34 | self.assertEqual(candidate.version, '0.2') 35 | 36 | def test_find_version_rolefile(self): 37 | candidate = Task(os.path.join(self.cwd, 'test_cases', 'test_role_v0.2', 38 | 'tasks', 'main.yml')) 39 | self.assertEqual(candidate.version, '0.2') 40 | 41 | def test_code_passes_flake8(self): 42 | # run flake8 against this source file 43 | candidate = Code(__file__.replace('.pyc', '.py')) 44 | result = code.code_passes_flake8(candidate, None) 45 | self.assertEqual(len(result.errors), 0) 46 | -------------------------------------------------------------------------------- /test/TestYamlReview.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | from ansiblereview.utils.yamlindent import yamlreview 4 | from ansiblereview.utils import Settings 5 | from ansiblereview import Playbook 6 | 7 | class TestYamlReview(unittest.TestCase): 8 | 9 | directory = os.path.dirname(__file__) 10 | 11 | def test_yaml_failures(self): 12 | candidate = Playbook(os.path.join(self.directory, 'yaml_fail.yml')) 13 | settings = Settings({}) 14 | result = yamlreview(candidate, settings) 15 | self.assertEqual(len(result.errors), 3) 16 | 17 | def test_yaml_success(self): 18 | candidate = Playbook(os.path.join(self.directory, 'yaml_success.yml')) 19 | settings = Settings({}) 20 | result = yamlreview(candidate, settings) 21 | self.assertEqual(len(result.errors), 0) 22 | -------------------------------------------------------------------------------- /test/diff.txt: -------------------------------------------------------------------------------- 1 | diff --git a/test1 b/test1 2 | new file mode 100644 3 | index 00000000..af5e5a13 4 | --- /dev/null 5 | +++ b/test1 6 | @@ -0,0 +1 @@ 7 | +μ 8 | 9 | -------------------------------------------------------------------------------- /test/inventory/group_vars/application-prod: -------------------------------------------------------------------------------- 1 | app-prod: x 2 | azA: common 3 | app: prod 4 | -------------------------------------------------------------------------------- /test/inventory/group_vars/application-stage: -------------------------------------------------------------------------------- 1 | app-stage: x 2 | azB: common 3 | app: stage 4 | -------------------------------------------------------------------------------- /test/inventory/group_vars/azA: -------------------------------------------------------------------------------- 1 | azA: different 2 | -------------------------------------------------------------------------------- /test/inventory/hosts: -------------------------------------------------------------------------------- 1 | [application:children] 2 | application-prod 3 | application-stage 4 | 5 | [application-prod] 6 | app-prod-A 7 | app-prod-B 8 | 9 | [application-stage] 10 | app-stage-A 11 | app-stage-B 12 | 13 | [azA] 14 | app-prod-A 15 | app-stage-A 16 | -------------------------------------------------------------------------------- /test/lintrules/TestTaskFailureRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | 3 | 4 | class TestTaskFailureRule(AnsibleLintRule): 5 | id = 'TEST0001' 6 | shortdesc = 'Test failure rule for ansible-review tasks - always fails' 7 | description = 'Always fails' 8 | tags = ['deprecated'] 9 | 10 | def matchtask(self, file, task): 11 | return True 12 | -------------------------------------------------------------------------------- /test/lintrules/TestTaskSuccessRule.py: -------------------------------------------------------------------------------- 1 | from ansiblelint import AnsibleLintRule 2 | 3 | 4 | class TestTaskSuccessRule(AnsibleLintRule): 5 | id = 'TEST0002' 6 | shortdesc = 'Test success rule for ansible-review tasks - always succeeds' 7 | description = 'Always fails' 8 | tags = ['deprecated'] 9 | 10 | def matchtask(self, file, task): 11 | return False 12 | -------------------------------------------------------------------------------- /test/standards/standards.py: -------------------------------------------------------------------------------- 1 | from ansiblereview import Standard, Result, Error, lintcheck 2 | from ansiblereview.groupvars import same_variable_defined_in_competing_groups 3 | 4 | 5 | def check_fail(candidate, settings): 6 | return Result(candidate,[Error(1, "test failed")]) 7 | 8 | 9 | def check_success(candidate, settings): 10 | return Result(candidate) 11 | 12 | test_task_ansiblelint_success = Standard(dict( 13 | check = lintcheck('TEST0002'), 14 | name = "Test task lint success", 15 | version = "0.2", 16 | types = ["playbook", "tasks", "handlers"] 17 | )) 18 | 19 | test_task_ansiblelint_failure = Standard(dict( 20 | check = lintcheck('TEST0001'), 21 | name = "Test task lint failure", 22 | version = "0.4", 23 | types = ["playbook", "tasks", "handlers"] 24 | )) 25 | 26 | test_failure = Standard(dict( 27 | check = check_fail, 28 | name = "Test general failure", 29 | version = "0.5", 30 | types=["playbook", "task", "handler", "rolevars", 31 | "hostvars", "groupvars", "meta"] 32 | )) 33 | 34 | test_success = Standard(dict( 35 | check = check_success, 36 | name = "Test general success", 37 | version = "0.2", 38 | types = "playbook,tasks,vars" 39 | )) 40 | 41 | test_matching_groupvar = Standard(dict( 42 | check = same_variable_defined_in_competing_groups, 43 | name = "Same variable defined in siblings", 44 | types = "groupvars" 45 | )) 46 | 47 | 48 | 49 | standards = [ 50 | test_task_ansiblelint_success, 51 | test_task_ansiblelint_failure, 52 | test_success, 53 | test_failure, 54 | test_matching_groupvar, 55 | ] 56 | -------------------------------------------------------------------------------- /test/test_cases/hosts: -------------------------------------------------------------------------------- 1 | [grandparent:children] 2 | parent 3 | 4 | [parent] 5 | child 6 | -------------------------------------------------------------------------------- /test/test_cases/test_playbook_0.2.yml: -------------------------------------------------------------------------------- 1 | # Standards: 0.2 2 | --- 3 | - hosts: localhost 4 | connection: local 5 | 6 | tasks: 7 | - name: say hello 8 | debug: msg=hello 9 | -------------------------------------------------------------------------------- /test/test_cases/test_role_unversioned/meta/main.yml: -------------------------------------------------------------------------------- 1 | # no standards 2 | -------------------------------------------------------------------------------- /test/test_cases/test_role_unversioned/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: this is a task 2 | debug: msg="hello" 3 | -------------------------------------------------------------------------------- /test/test_cases/test_role_v0.2/meta/main.yml: -------------------------------------------------------------------------------- 1 | # Standards: 0.2 2 | -------------------------------------------------------------------------------- /test/test_cases/test_role_v0.2/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: this is a task 2 | debug: msg="hello" 3 | -------------------------------------------------------------------------------- /test/test_cases/test_role_v0.5/meta/main.yml: -------------------------------------------------------------------------------- 1 | # Standards: 0.5 2 | -------------------------------------------------------------------------------- /test/test_cases/test_role_v0.5/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: this is a task 2 | debug: msg="hello" 3 | -------------------------------------------------------------------------------- /test/yaml_fail.yml: -------------------------------------------------------------------------------- 1 | - start: 2 | - overindented 3 | - misaligned 4 | - next: 5 | - underindented 6 | -------------------------------------------------------------------------------- /test/yaml_success.yml: -------------------------------------------------------------------------------- 1 | - tasks: 2 | - block: 3 | - name: hello 4 | command: echo hello 5 | - name: task2 6 | debug: 7 | msg: hello 8 | when: some_var_is_true 9 | - name: another task 10 | debug: 11 | msg: another msg 12 | - fail: 13 | msg: this is actually valid indentation 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 1.6 3 | envlist = py27-ansible{19,20,21},py{27,36}-ansible{22,23,24,25,devel},py{27,36}-flake8 4 | 5 | [testenv] 6 | deps = 7 | ansible19: ansible>=1.9.4,<2 8 | ansible20: ansible>=2.0.0.2,<2.1 9 | ansible21: ansible>=2.1,<2.2 10 | ansible22: ansible>=2.2,<2.3 11 | ansible23: ansible>=2.3,<2.4 12 | ansible24: ansible>=2.4,<2.5 13 | ansible25: ansible>=2.5,<2.6 14 | ansibledevel: git+https://github.com/ansible/ansible.git 15 | ansible-lint>=3.0.0 16 | -rtest-deps.txt 17 | commands = nosetests [] 18 | passenv = HOME 19 | 20 | [testenv:py27-flake8] 21 | commands = flake8 lib 22 | usedevelop = True 23 | 24 | [testenv:py36-flake8] 25 | commands = flake8 lib 26 | usedevelop = True 27 | --------------------------------------------------------------------------------