It has proven useful to agree on certain guiding principles as early as possible in any automation project. Doing so makes it much easier to onboard new Ansible developers. Project guidelines can also be shared with other departments working on automation which in turn improves the re-usability of playbooks, roles, modules, and documentation.
Another major benefit is that it makes code review process less time-consuming and more reliable; making both the developer and reviewer more likely to engage in a constructive review conversation.
This section contains suggestions for such coding-style guidelines. The list is neither complete nor are all of the guidelines necessary in every automation project. Experience shows that it makes sense to start with a minimum set of guidelines because the longer the list the lower the chance of people actually reading through it. Additional guidelines can always be added later should the situation warrant it.
-
Use valid Python identifiers following standard naming conventions of being in
snake_case_naming_schemes
for all YAML or Python files, variables, arguments, repositories, and other such names (like dictionary keys). -
Do not use special characters other than underscore in variable names, even if YAML/JSON allow them.
Details
- Explanation
-
Using such variables in Jinja2 or Python would be then very confusing and probably not functional.
- Rationale
-
even when Ansible currently allows names that are not valid identifier, it may stop allowing them in the future, as it happened in the past already. Making all names valid identifiers will avoid encountering problems in the future. Dictionary keys that are not valid identifiers are also less intuitive to use in Jinja2 (a dot in a dictionary key would be particularly confusing).
-
Use mnemonic and descriptive names that are human-readable and do not shorten more than necessary. A pattern
object[_feature]_action
has proven useful as it guarantees a proper sorting in the file system for roles and playbooks. Systems support long identifier names, so use them! -
Avoid numbering roles and playbooks, you’ll never know how they’ll be used in the future.
-
Name all tasks, plays, and task blocks to improve readability.
-
Write task names in the imperative (e.g. "Ensure service is running"), this communicates the action of the task.
-
Avoid abbreviations in names, or use capital letter for abbreviations where it cannot be avoided.
-
Indent at two spaces
-
Indent list contents beyond the list definition
Details
Do this:example_list: - example_element_1 - example_element_2 - example_element_3 - example_element_4
Don’t do this:example_list: - example_element_1 - example_element_2 - example_element_3 - example_element_4
-
Split long expressions into multiple lines.
Details
- Rationale
-
long lines are difficult to read, many teams even ask for a line length limit around 120-150 characters.
- Examples
-
there are multiple ways to avoid long lines but the most generic one is to use the YAML folding sign (
>
):Usage of the YAML folding sign- name: Call a very long command line ansible.builtin.command: > echo Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas mollis, ante in cursus congue, mauris orci tincidunt nulla, non gravida tortor mi non nunc. - name: Set a very long variable ansible.builtin.set_fact: meaningless_variable: >- Ut ac neque sit amet turpis ullamcorper auctor. Cras placerat dolor non ipsum posuere malesuada at ac ipsum. Duis a neque fermentum nulla imperdiet blandit.
Tipuse the sign >-
if it is important that the last line return code doesn’t become part of the string (e.g. when defining a string variable).
-
If the
when:
condition results in a line that is too long, and is anand
expression, then break it into a list of conditions.Details
- Rationale
-
Ansible will
and
the list elements together (Ansible UseGuide » Conditionals). Multiple conditions that all need to be true (a logicaland
) can also be specified as a list, but beware of bare variables inwhen:
. - Examples
-
Do this
when: - myvar is defined - myvar | bool
instead of thiswhen: myvar is defined and myvar | bool
-
All roles need to, minimally, pass a basic ansible-playbook syntax check run
-
Spell out all task arguments in YAML style and do not use
key=value
type of argumentsDetails
Do this:tasks: - name: Print a message ansible.builtin.debug: msg: This is how it's done.
Don’t do this:tasks: - name: Print a message ansible.builtin.debug: msg="This is the exact opposite of how it's done."
-
Use
true
andfalse
for boolean values in playbooks.Details
- Explanation
-
Do not use the Ansible-specific
yes
andno
as boolean values in YAML as these are completely custom extensions used by Ansible and are not part of the YAML spec and also avoid the use of the Python-styleTrue
andFalse
for boolean values in playbooks. - Rationale
-
YAML 1.1 allows all variants whereas YAML 1.2 allows only true/false, and we want to be ready for when it becomes the default, and avoid a massive migration effort.
-
Avoid comments in playbooks when possible. Instead, ensure that the task
name
value is descriptive enough to tell what a task does. Variables are commented in thedefaults
andvars
directories and, therefore, do not need explanation in the playbooks themselves. -
Use a single space separating the template markers from the variable name inside all Jinja2 template points. For instance, always write it as
{{ variable_name_here }}
. The same goes if the value is an expression.{{ variable_name | default('hiya, doc') }}
-
When naming files, use the
.yml
extension and not.yaml
..yml
is whatansible-galaxy init
does when creating a new role template. -
Use double quotes for YAML strings with the exception of Jinja2 strings which will use single quotes.
-
Do not use quotes unless you have to, especially for short module-keyword-like strings like
present
,absent
, etc. But do use quotes for user-side strings such as descriptions, names, and messages. -
Even if JSON is valid YAML and Ansible understands it, do only use JSON syntax if it makes sense (e.g. a variable file automatically generated) or adds to the readability. In doubt, nobody expects JSON so stick to YAML.
-
Break up lengthy Jinja templates into multiple templates when there are distinct logical sections.
Details
- Rationale
-
Long and complex Jinja templates can be difficult to maintain and debug. By splitting excessively long templates into logical componets that can be included as-needed, each template will be easier to maintain.
-
Jinja templates should not be used to create structured data but instead text and semi-structured data. Filter plugins are preferred over Jinja templates for the use of data manipulation or transformation.
Details
- Rationale
-
When working with structured data or data transformations it is preferable to use a programming language (such as Python) that has better support and tooling to do this kind of work. Custom filter plugins can be written to handle complex or unique use-cases. Tasks will be much more legible if data is managed and manipulated via plugins than with in-line Jinja.
-
Ensure that all tasks are idempotent.
-
Prefer the command module over the shell module unless you explicitly need shell functionality such as, e.g., piping. Even better, use a dedicated module, if it exists. If not, see the section about idempotency and check mode and make sure that your task is idempotent and supports check mode properly; your task will likely need options such as
changed_when:
and maybecheck_mode:
). -
Anytime
command
orshell
modules are used, add a comment in the code with justification to help with future maintenance. -
Use the
| bool
filter when using bare variables (expressions consisting of just one variable reference without any operator) inwhen
. -
Break complex task files down into discrete parts.
Details
- Rationale
-
Task files that are very or and/or contain highly nested blocks are difficult to maintain. Breaking a large or complex task file into multiple discrete files makes it easier to read and understand what is being done in each part.
-
Use bracket notation instead of dot notation for value retrieval (e.g.
item['key']
vs.item.key
)Details
- Rationale
-
Dot notation will fail in some cases (such as when a variable name includes a hyphen) and it’s better to stay consistent than to switch between the two options within a role or playbook. Additionally, some key names collide with attributes and methods of Python dictionaries such as
count
,copy
,title
, and others (refer to the Ansible User Guide for an extended list) - Example
-
This post provides an excellent demonstration of how using dot notation syntax can impact your playbooks.
-
Do not use
meta: end_play
.Details
- Rationale
-
It aborts the whole play instead of a given host (with multiple hosts in the inventory). If absolutely necessary, consider using
meta: end_host
.
-
Task names can be made dynamic by using variables wrapped in Jinja2 templates at the end of the string
Details
- Rationale
-
This can help with reading the logs. For example, if the task is managing one of several devices, and you want the task name output to show the device being managed. However, the template must come at the end of the string - see (Ansible Lint name template rule). Note that in some cases, it can make it harder for users to correlate the logs to the code. For example, if there is a log message like "Manage the disk device /dev/dsk/0001", and the user tries to do something like
grep "Manage the disk device /dev/dsk/0001" rolename/tasks/*.yml
to figure out which task this comes from, they will not find it. If the template comes at the end of the string, the user will know to omit the device name fromgrep
. A better way to debug is to useansible-playbook -vv
, which will show the exact file and line number of the task. - Example
-
.Do this:
tasks: - name: Manage the disk device {{ storage_device_name }} some.module: device: "{{ storage_device_name }}"
Don’t do this:tasks: - name: Manage {{ storage_device_name }}, the disk device some.module: device: "{{ storage_device_name }}"
-
Do not use variables (wrapped in Jinja2 templates) for play names; variables don’t get expanded properly there. The same applies to loop variables (by default
item
) in task names within a loop. They, too, don’t get properly expanded and hence are not to be used there. -
Do not override role defaults or vars or input parameters using
set_fact
. Use a different name instead.Details
- Rationale
-
a fact set using
set_fact
can not be unset and it will override the role default or role variable in all subsequent invocations of the role in the same playbook. A fact has a different priority than other variables and not the highest, so in some cases overriding a given parameter will not work because the parameter has a higher priority (Ansible User Guide » Using Variables)
-
Use the smallest scope for variables. Facts are global for playbook run, so it is preferable to use other types of variables. Therefore limit (preferably avoid) the use of
set_fact
. Role variables are exposed to the whole play when the role is applied usingroles:
orimport_role:
. A more restricted scope such as task or block variables is preferred. -
Beware of
ignore_errors: true
; especially in tests. If you set on a block, it will ignore all the asserts in the block ultimately making them pointless. -
Do not use the
eq
,equalto
, or==
Jinja tests introduced in Jinja 2.10, use Ansible built-inmatch
,search
, orregex
instead.Details
- Explanation
-
The issue is only with Jinja versions older than 2.10. RPM distributions of Ansible generally use the underlying OS platform python library for Jinja e.g. python-jinja2. This is especially problematic on EL7. The only supported Ansible RPM on that platform is 2.9, which uses the EL7 platform python-jinja2 library, which is 2.7 (and will likely never be upgraded). As of mid-2022, there are many users using EL7 for the control node. I believe this means AAP 1.x users will also be affected. Users not affected:
-
AAP 2.x users - there should be an option to use EL8 runners, or otherwise, build the EEs in such a way as to use Jinja 2.11 or later
-
Users running Ansible from a pip install
-
Users running Ansible installed via RPM on EL8 or later
-
- Rationale
-
These tests are not present in versions of Jinja older than 2.10, which are used on older controller platforms, such as EL7. If you want to ensure that your code works on older platforms, use the built-in Ansible tests such as (match), (search), or (regex) instead.
- Example
-
You have a
list
ofdict
, and you want to filter out elements that have the keytype
with the valuebad_type
.
Do this:tasks: - name: Do something some.module: param: "{{ list_of_dict | rejectattr('type', 'search', '^bad_type$') | list }}"
Don’t do this:tasks: - name: Do something some.module: param: "{{ list_of_dict | rejectattr('type', 'eq', 'bad_type') | list }}"
When using
match
,search
, orregex
, and you want an exact match, you must specify the regex^STRING$
, otherwise, you will match partial strings. -
Avoid the use of
when: foo_result is changed
whenever possible. Use handlers, and, if necessary, handler chains to achieve this same result. -
Use the various include/import statements in Ansible.
Details
- Explanation
-
Doing so can lead to simplified code and a reduction of repetition. This is the closest that Ansible comes to callable sub-routines, so use judgment about callable routines to know when to similarly include a sub playbook. Some examples of good times to do so are
-
When a set of multiple commands share a single
when
conditional -
When a set of multiple commands are being looped together over a list of items
-
When a single large role is doing many complicated tasks and cannot easily be broken into multiple roles, but the process proceeds in multiple related stages
-
-
Avoid calling the
package
module iteratively with the{{ item }}
argument, as this is impressively more slow than calling it with the linename: "{{ foo_packages }}"
. The same can go for many other modules that can be given an entire list of items all at once. -
Use meta modules when possible.
Details
- Rationale
-
This will allow our playbooks to run on the widest selection of operating systems possible without having to modify any more tasks than is necessary.
- Examples
-
-
Instead of using the
upstart
andsystemd
modules, use theservice
module when at all possible. -
Similarly for package management, use
package
instead ofyum
ordnf
or similar.
-
-
Avoid the use of
lineinfile
wherever that might be feasible.Details
- Rationale
-
Slight miscalculations in how it is used can lead to a loss of idempotence. Modifying config files with it can cause the Ansible code to become arcane and difficult to read, especially for someone not familiar with the file in question. Try editing files directly using other built-in modules (e.g.
ini_file
,blockinfile
,xml
), or reading and parsing. If you are modifying more than a tiny number of lines or in a manner more than trivially complex, try leveraging thetemplate
module, instead. This will allow the entire structure of the file to be seen by later users and maintainers. The use oflineinfile
should include a comment with justification. Alternatively, most configuration files have their own modules, such as community.general.ssh_config or community.general.nmcli. Using these make code cleaner to read and ensure idempotence.
-
Limit use of the
copy
module to copying remote files, static files, and to uploading binary blobs. For most file pushes, use thetemplate
module. Even if there currently is nothing in the file that is being templated, if there is the possibility in the future that it might be added, having the file handled by thetemplate
module now makes adding that functionality much simpler than if the file is initially handled by thecopy
module and then needs to be moved before it can be edited. -
When using the
template
module, append.j2
to the template file name.Details
- Example
-
If you want to use the
ansible.builtin.template
module to create a file calledexample.conf
somewhere on the managed host, name the template for this filetemplates/example.conf.j2
. - Rationale
-
When you are at the stage of writing a template file you usually already know how the file should end up looking on the file system, so at that point it is convenient to use Jinja2 syntax highlighting to make sure your templating syntax checks out. Should you need syntax highlighting for whatever language the target file should be in, it is very easy to define in your editor settings to use, e.g., HTML syntax highlighting for all files ending in
.html.j2
. It is much less straightforward to automatically enable Jinja2 syntax highlighting for some files ending on.html
.
-
Keep filenames and templates as close to the name on the destination system as possible.
Details
- Rationale
-
This will help with both editor highlighting as well as identifying source and destination versions of the file at a glance. Avoid duplicating the remote full path in the role directory, however, as that creates unnecessary depth in the file tree for the role. Grouping sets of similar files into a subdirectory of
templates
is allowable, but avoid unnecessary depth to the hierarchy.
-
Using agnostic modules like
package
only makes sense if the features required are very limited. In many cases, if the platform is different, the package name is also different so that usingpackage
doesn’t help a lot. Prefer then the more specificyum
,dnf
orapt
module if you anyway need to differentiate. -
Use
float
,int
, andbool
filters to "cast" public API variables to ensure type safety, especially for numeric operations in Jinja.Details
- Example
-
Variables set by users in the public API are not guaranteed to be any specific data type, and may be
str
type when some numeric type is expected:
> ansible -c local -i localhost --extra-vars int_val=1 localhost -m debug -a "msg={{ int_val < 0 }}" localhost | FAILED! => { "msg": "Unexpected templating type error occurred on ({{ int_val < 0 }}): '<' not supported between instances of 'str' and 'int'" }
- Rationale
-
It is generally not possible to guarantee that all user inputs retain their desired numeric type, and if not, will likely be
str
type. If you use numeric variables where the value comes from user input, use thefloat
,int
, andbool
filters to "cast" the values to the type for numeric operations. If you are simply converting the value to a string, you do not have to use the cast. Numeric operations include:-
arithmetic:
int_var + 3
,float_var * 3.14159
-
comparison:
int_var == 0
,float_var >= 2.71828
-
unary:
-int_var
,+float_var
-
Here are some examples:
> ansible -c local -i localhost --extra-vars int_val=1 localhost -m debug -a "msg={{ int_val | int < 0 }}" localhost | SUCCESS => { "msg": false } > ansible -c local -i localhost -e float_val=0.5 localhost -m debug -a "msg='float_val is less than 1.0 {{ float_val | float + 0.1 < 1.0 }}'" localhost | SUCCESS => { "msg": "float_val is less than 1.0 True" }