title | owner |
---|---|
Ansible Coding Standards |
Matt Calvert |
It's worth reading the Ansible Best Practices document as well as this associated blog post which should cover the basics.
Everything should follow Ansible best-practice unless there's a very good reason not to.
The 'KISS' principle always applies. While we like to avoid reinventing the wheel, please be careful if using third-party playbooks or roles. They may set things up differently or in a less secure manner than how we'd do it.
Use the native long-form YAML syntax, not the older key=value
style syntax.
Do use this format:
- yum:
name: docker
state: present
Don't use this format:
- yum: { name: docker, state: present }
And don't use this format:
- yum: name=docker state=present
As of Ansible 2.10 we recommend using fully qualified module names to avoid module name conflicts.
Do use this format:
- name: Create container
community.docker.docker_container:
name: mydata
image: busybox
...
Don't use this format:
- name: Create container
docker_container:
name: mydata
image: busybox
...
Variable names should follow the convention of {{ role_name}}_{{ variable_name}}
, e.g. php_install_version
, common_additional_packages
, magento_web_php_version
, etc. This should prevent variable name conflicts down the line.
You should not define a top-level dictionary to house all role variables, as tempting as this may be. Overriding a single dictionary item will cause all other items in that dictionary to also be overridden, and playbooks will then fail due to undefined variables. For example:
Do use this format:
apache_max_workers: 8
apache_bind_port: 80
apache_default_document_root: /var/www/html
Don't use this format:
apache:
max_workers: 8
bind_port: 80
default_document_root: /var/www/html
Every variable used in a playbook should be defined in the defaults file, {{ role }}/defaults/main.yml
, wherever possible. The default values assigned to those variables should cover the most likely use case.
If a variable's value will result in a destructive action, this must be disabled by default. It is then the responsibility of the operator to override that value. e.g.
filesystems_destroy_partitions: false
In cases where lists are used and no defaults are needed, you should define an empty list with variable: []
In cases where dictionaries are used and no defaults are needed, you should define an empty dictionary with variable: {}
These ensure the variables have the correct type.
Although it is possible, you should not define variable defaults within plays themselves. The only exceptions are when iterating a list of dictionaries and a variable may not be defined in the dictionary.
For example, look at the value of group.proto
in this Jinja2 block:
{% for group in iptables_input_rule_groups %}
:{{ group.name | upper }} - [0:0]
-A INPUT -m state --state NEW -m {{ group.proto | default("tcp") }} -p {{ group.proto | default("tcp") }} --dport {{ group.port }} -j {{ group.name | upper }}
{% for net in group.networks %}
-A {{ group.name | upper }} -s {{ net.subnet }}{% if net.comment is defined %} -m comment --comment "{{ net.comment }}"{% endif %} -j ACCEPT
{% endfor %}
{% endfor %}
If proto
was not defined in the dictionary group
, the templated output would be invalid when IPTables tries to parse it. In this instance, the default value should be defined, as we see with the "| default(tcp)
" bit.
Your role must work without the hash_behaviour=merge
option (as is the default). This was used in the past by some of our roles but we have moved away from this option since.
Every role must have an Ansible Galaxy metadata file included in it, or Ansible Galaxy will not be able use it. Check the docs for more info.
An MIT LICENSE
file and CODEOWNERS.md
files should be included in every git repository.
A README.md
file should be present for every Ansible role with these sections:
- Role title
- Role description
- Build Status
- Support OSes
- Dependencies
- Role Variables
- Including the default values, or a link to the
<role>/defaults/main.yml
file
- Including the default values, or a link to the
- Example Playbook
- This example should demonstrate the role and (some) of its variables being used.
You should use underscores for all roles, variables, inventory names, inventory groups, etc. For a long time, Ansible has converted these these at runtime automatically without emiting any warnings, but this functionality is now deprecated.
Where Jinja templates are used, you should ensure that {{ ansible_managed | comment }}
is placed at the start of the file. Further information can be found here.
You should avoid using ansible_managed
in 'one shot' deployment situations (i.e. where we won't re-run the Ansible playbook/role again), as these files won't be managed once the installation is completed and we don't want to confuse engineers. If you don't want to add the comment every time, this may be added behind a feature flag as long as the default is false, e.g. my_role_add_ansible_managed: false
. Then, in the template, you can add {{ ansible_managed if my_role_add_ansible_managed else '' }}
The command
and shell
modules should not be used unless absolutely necessary. Always try to use Ansible modules wherever possible.
You should not ignore deprecation warnings on your roles. Tackling these early will avoid pain down the line. If you discover that a new Ansible release has caused these, you must take the remedial action.
For roles being used by the auto-installer, generic roles (e.g. 'PHP' or 'MySQL') must work on all our supported distributions. Specific software configurations that we support only on a particular distribution, e.g. Magento + CentOS, are fine to target only that distribution. These distributions are:
- CentOS 7
- AlmaLinux 8, 9
- Ubuntu 18.04 and 20.04, 22.04
- Debian 9, 10 and 11 (Stretch, Buster and Bullseye)
All roles should have accompanying tests using Molecule v3 and Docker as the Molecule driver. Vagrant may be used in cases where Docker is not suitable however (e.g. amending /etc/resolv.conf
).
At a minimum, they should apply your role onto all distributions targeted by your role (see "Support all OSes" above).
GitLab CI should be used to test your role on every push to the repository on all branches, including master
/main
.
You should use an official Molecule docker image (e.g. quay.io/ansible/molecule:3.6.1
).
Your roles must pass yamllint
and ansible-lint
linters without errors or warnings. Molecule runs these for you by default, and you should not disable them.
It's important that when roles are amended in any way, that they are kept backwards compatible. Not doing so will cause issues when the updated version is used in other playbooks. Therefore, you must make sure that you don't change variable names unless you are absolutely certain that they have been updated in all playbooks that use it.
- You should use inventory directories to contain all variables for the target environment, in
/inventory
. - You should keep all roles in the path
/roles
. - You should keep Ansible Galaxy
requirements.yml
file in the/roles
directory. - You must store additional files (like certificates) in the
/files
directory. These can be referred to using{{ role_path }}/../../files/{{ file_required }}
This is an example Playbook directory structure:
<root>
├─ files/
│ ├─ certificates/
│ │ ├─ STAR.domain.co.uk/
│ │ │ ├─ private_key.yml
│ │ │ ├─ certificate.yml
│ │ │ └─ ca-bundle.yml
│ │ └─ ...
│ └─ ...
├─ inventory/
│ ├─ prod/
│ │ ├─ host_vars/
│ │ │ ├─ k8s-master-01/
│ │ │ │ ├─ linux_hardening.yml
│ │ │ │ └─ ...
│ │ │ └─ ...
│ │ └─ group_vars/
│ │ ├─ nodes/
│ │ │ ├─ iptables.yml
│ │ │ └─ ...
│ │ └─ ...
│ └─ uat:
│ ├─ host_vars/
│ │ ├─ k8s-master-01/
│ │ │ ├─ linux_hardening.yml
│ │ │ ├─ packages.yml
│ │ │ └─ ...
│ │ └─ ...
│ └─ group_vars/
│ ├─ nodes/
│ │ ├─ iptables.yml
│ │ └─ ...
│ └─ ...
├─ roles/
│ ├─ iptables/
│ │ ├─ defaults/
│ │ │ ├─ main.yml
│ │ ├─ meta/
│ │ │ ├─ main.yml
│ │ ├─ tasks/
│ │ │ ├─ main.yml
│ │ └─ ...
│ ├─ requirements.yml
│ └─ ...
This enables reuse of the playbook against other environments like Prod, UAT, Dev, etc.
You should endeavour to use our existing roles wherever possible. These are housed in GitLab here:
If the role does not do exactly what you need, contact the maintainer of that role to see if they are open to making the role do what you require of it. If so, create a Merge Request with the changes or ask the maintainer to review the work. Check the CODEOWNERS.md
file in the role's Git repository to identify the maintainer.
If a role doesn't exist to do what you require, consider writing your own, or check the public Ansible Galaxy listings for a well maintained role to do it. geerlingguy
's roles are usually very good.
You should use Ansible Galaxy to pull in external roles for use in your playbook. You should not download and copy them into your playbook, as that will cause problems with keeping it maintained.
You should use a file called /roles/requirements.yml
to identify which external roles are used. The format is documented here and looks like this:
---
- src: git@your-gitlab-server-fqdn:ansible-roles/zabbix-agent.git
scm: git
version: "master"
- src: git@your-gitlab-server-fqdn:ansible-roles/filebeat.git
scm: git
version: "master"
Whenever an external role is used, you must add that role to your Git repository's .gitignore
file, so that it doesn't get added into your Git commits by mistake
Variables files in host_vars
and group_vars
must be named in the format {{ role_name }}.yml
and stored in a directory named after the host or host group it should apply to.
For example:
Key | Value |
---|---|
Inventory name | prod |
Role name | pacemaker_resource_haproxy |
Host group | lbs |
Variable file path | /inventory/prod/group_vars/lbs/pacemaker_resource_haproxy.yml |
You should use an ansible.cfg
file in every Ansible playbook to configure your environment. This greatly simplifies using Ansible, as this file points to your role paths, inventory file, ansible-vault secrets, etc.
This file should be stored in /inventory/{{ inventory_name }}/ansible.cfg
. This is an example of one:
[defaults]
roles_path = ../../.external-roles:../../roles
inventory = ./hosts.ini
retry_files_enabled = False
stdout_callback = skippy
timeout = 60
[privilege_escalation]
become=True
become_user=root
Notice how roles_path
is set. Two different paths are defined. The reason for this is, Ansible Galaxy will install external roles in the first entry in the paths list, ../../.external-roles
in this case. This leaves you free to use ../../roles
for your playbook's own roles, without affecting those brought in from external sources.
You should reference the ansible.cfg
file in your user's environment by adding this to the user's ~/.profile
, ~/.bashrc
, ~/.zshrc
(or otherwise) file:
export ANSIBLE_CONFIG=/path/to/your/environments/ansible.cfg
This saves you forgetting to set it, and avoids roles being installed in the wrong place on disk.
You must use Ansible Vault to encrypt any secrets before they are committed to source control (Git). This is imperative, as once an unencrypted secret is added to Git, it can be hard to remove it later. Even if you change it in a later commit, it will still be there in the Git history (git log
). If you do this by mistake, you'll need to use an interactive rebase to edit or drop that commit.
You should not store the Ansible Vault password in plain text format anywhere, even as a hidden file on disk. Ansible will prompt you for the password when you run it.
You must use versions of Ansible and Python that are under active maintenance.
You should not use Python v2 unless absolutely necessary.
The currently supported releases can be found on the Ansible and Python sites.
Python Virtual Environments (venvs) must be used wherever possible, as they provide a separation of Ansible and it's requirements from the operating system's Python libraries. This keeps things much cleaner and avoids conflicts with the OS's package manager.
As an alternative, PyEnv can be used and will allow you to install the exact version of Python you need. The amount of disk space required is much larger, however.