Skip to content

Instantly share code, notes, and snippets.

@miff2000
Last active February 15, 2023 06:50
Show Gist options
  • Save miff2000/032b68ca4419d39e3f47a35e9faa982b to your computer and use it in GitHub Desktop.
Save miff2000/032b68ca4419d39e3f47a35e9faa982b to your computer and use it in GitHub Desktop.
Ansible Standards
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.

Follow Ansible best practices

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.

Guidelines for developing roles

Use the correct Play format

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

Use fully qualified module names

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
    ...

Use sensible variable naming

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.

Avoid top-level dictionaries

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

Define your default variables and sensible values

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.

Avoid defining defaults within plays

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.

Check the hash behaviour

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.

Include meta/main.yml, README.md, LICENSE and CODEOWNERS.md files

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
  • Example Playbook
    • This example should demonstrate the role and (some) of its variables being used.

Use underscores wherever possible

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.

Use ansible_managed in Jinja templates

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 '' }}

Avoid shelling out

The command and shell modules should not be used unless absolutely necessary. Always try to use Ansible modules wherever possible.

Don't ignore deprecation warnings

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.

Testing

Support all OSes

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)

Use Molecule tests

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).

Use CI/CD to test

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).

Use Linting to govern standards

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.

Updating / extending roles

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.

Guidelines for developing playbooks

Directory structure

  • 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.

Use existing roles, where possible

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.

Externalise roles and use Ansible Galaxy with requirements files

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

Name the host_vars and group_vars files consistently

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

Use an ansible.cfg file to make life easier

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.

Set the ANSIBLE_CONFIG variable in the user profile

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.

Use Ansible Vault to encrypt secrets

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.

Deployment environment guidelines

Use Ansible 2.10+ with Python 3.8+

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.

Use Python virtual environments

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment