Cet article est disponible en français

Private variables with Ansible

Ansible doesn’t offer any syntax for what is known in high level programming languages as encapsulation: every variable is public. This situation is probably not going to change any time soon1. It is nevertheless possible to simulate this functionality à la Python with a naming convention and a bit of rigorousness.

The file default/main.yml is the primary place to define the variables of a role, especially so for variables with a default value, hence its name. As far as I know every one agrees the content of this file is public, some going as far as calling it a living documentation. This is step too far for me as I prefer my role documentation to be inside a README.md.

In order to have “hidden” variables less easily overridden by users some people use the vars folder which has a higher priority order. However a role’s vars is still lower than a playbook’s extra_vars making this attempt moot. For this reason I do not try hide my private variables and just annotate them with prefix: a double underscore sign.

These two use cases are different and will therefore be detailed in separate sections.

Mandatory variables

Ansible offers many filters in order to manipulate data more comfortably. One of interest to me and to this article is the mandatory filter. It allows to stop Ansible from starting if a particular variable is not defined beforehand.

One naive approach regarding this filter is to simply use it at the first place a variable is used and not to report it in defaults/main.yml.

Example:

$ cat defaults/main.yml
---
default_groups:
  - staff
  - wheel
$ cat task/main.yml
---
- name: Create user
  user:
    name: "{{ user_name | mandatory }}"
    group: "{{ user_name }}"
    groups: "{{ default_groups }}"

Here the variable default_groups is present as it should in defaults/main.yml but user_name is not. I think this approach to be problematic. As stated earlier the file defaults/main.yml is often used as a documentation for a role’s available options, some authors even add comments illustrating various way of combining variables together. If a variable is not present in this file it is far harder to spot and as a result the comprehension of the role as a whole is lessened.

This is where a private variable can shine: by defining its value as the result of the public variable with the mandatory filter it is possible to hold every variables in the defaults/main.yml.

Improved example:

$ cat defaults/main.yml
---
default_groups:
  - staff
  - wheel
__user_name: "{{ user_name | mandatory }}"
$ cat task/main.yml
---
- name: Create user
  user:
    name: "{{ __user_name }}"
    group: "{{ __user_name }}"
    groups: "{{ default_groups }}"

As I disagree with the living documentation concept I always keep a well defined README.md with a short description for every variable, their default values if any, etc… In this case the private variables are not defined in the list.

Here is a fragment illustrating the concept:

## Role Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `default_groups` | List of groups to assign `{{ user_name }}` to | `['staff', 'wheel']` |
| `user_name` | User name | mandatory |

Variables whose value depends on the context

The second use case for private variables is for cases when a variable’s value depends on an external factor, such as the presence of particular version of a software (ie: rsyslog rather than syslog-ng) or the presence of a specific operating system.

As an example imagine a role dedicated to the installation and configuration of the imaginary application catodo, an offline task management software for cat owners.

Such a role is articulated quite simply2:

$ cat defaults/main.yml
---
catodo_user: catodo
catodo_group: catodo
catodo_config_file: /etc/catodo.conf
$ cat tasks/main.yml
---
- name: Ensure catodo is installed
  package:
    name: catodo
    state: present

- name: Ensure catodo is properly configured
  template:
    src: catodo.conf.j2
    dest: "{{ catodo_config_file }}"
    owner: "{{ catodo_user }}"
    group: "{{ catodo_group }}"
  notify: Restart catodo

This role is simple, clear and without any bells or whistles, but there is one problem: it works great with Debian but not at all with OpenBSD. For some reasons outside of the scope of this article the package is named ecatodo on this operating system, preventing the role from functioning correctly.

A naive approach is to create two tasks with conditional expression:

$ cat tasks/main.yml
---
- name: Linux - Ensure catodo is installed
  package:
    name: catodo
    state: present
  when: ansible_system == 'Linux'

- name: OpenBSD - Ensure ecatodo is installed
  package:
    name: ecatodo
    state: present
  when: ansible_system == 'OpenBSD'

But what to do if FreeBSD support become required? The package name might be different again, not to mention the configuration file being located in /usr/local/etc/catodo.conf. This method does not scale.

A better solution, or at least a more flexible one, is to rely on two tricks: the include_vars module and private variables.

First the include_vars module allows to dynamically load files from various places, in this case the vars directory. Second private variables can be used as a level of indirection between the default value for a given situation and the behaviour expected by a user.

Therefore by using facts it is possible to load specific default values for Linux or OpenBSD at run time.

Improved example:

$ cat defaults/main.yml
---
catodo_user: catodo
catodo_group: catodo
catodo_config_file: /etc/catodo.conf
catodo_package_name: "{{ __catodo_package_name }}"
$ cat tasks/main.yml
---
- name: Import OS-specific variables
  include_vars: "{{ ansible_system }}.yml"

- name: Ensure catodo is installed
  package:
    name: "{{ catodo_package_name }}"
    state: present

- name: Ensure catodo is properly configured
  template:
    src: catodo.conf.j2
    dest: "{{ catodo_config_file }}"
    owner: "{{ catodo_user }}"
    group: "{{ catodo_group }}"
  notify: Restart catodo
$ cat vars/OpenBSD.yml
---
__catodo_package_name: ecatodo
$ cat vars/Linux.yml
---
__catodo_package_name: catodo

It is possible to achieve greater granularity by creating files targeting specific Linux distributions in order to handle Debian and RedHat derivatives differently but this is not the scope of this article.

I do not handle the documentation of this variable the same way as in the first section as I do expose it in the README.md. After all the value is a default in a specific context.

## Role Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `catodo_user` | CaTodo system user name | `catodo` |
| `catodo_group` | CaTodo system group | `catodo` |
| `catodo_config_file` | Path to the CaTodo configuration file | `/etc/catodo.conf` |
| `catodo_package_name` | Package name | `{{ __catodo_package_name }}` |

### Linux

| Variable | Default |
|----------|---------|
| `__catodo_package_name` | `catodo` |

### OpenBSD

| Variable | Default |
|----------|---------|
| `__catodo_package_name` | `ecatodo` |

With this method multiple default values can be specified to ease installation on different operating systems while still allowing the role’s user to change them if necessary.

Conclusion

I use the same naming convention for two different use cases of what I call “private variables“, the first to define mandatory variables and the second to handle compatibility with external interactions.

It would be possible to combine the two methods in order to be sure Ansible correctly detects an unsupported operating system, but it is perhaps going too far:

$ cat defaults/main.yml
---
catodo_package_name: "{{ __catodo_package_name | mandatory }}"
__catodo_package_name: "{{ ____catodo_package_name }}"

  1. https://github.com/ansible/proposals/issues/109#issuecomment-384075214 

  2. I voluntarily did not include the catodo.conf.j2 template nor the handler Restart catodo to keep the example short. This is not a mistake.