ansible-public/CONVENTIONS.md

10 KiB

Conventions

Ansible managed, or not?

When a server has just a few modifications made via Ansible, it's often enough to put a block marker (if just a section of a file is managed by Ansible) or a {{ ansible_managed }} tag at the top of the files (if this file is fully managed by Ansible).

When a server is fully managed by Ansible, it's even better to use the ansible-managed role. It can be found in the ansible-roles repository. For the moment it only changes the "Message Of The Day" file (in /etc/motd). Don't forget to customize the project_repository variable ; it is used in this file.

Roles

We can use the ansible-galaxy init command to bootstrap a new role :

$ ansible-galaxy init foo
- foo was created successfully

$ tree foo
foo
├── defaults
│   └── main.yml
├── files
├── handlers
│   └── main.yml
├── meta
│   └── main.yml
├── README.md
├── tasks
│   └── main.yml
├── templates
├── tests
│   ├── inventory
│   └── test.yml
└── vars
    └── main.yml

All main.yml file will be picked up by Ansible automatically, with respect to their own responsibility.

The main directory is tasks. It will contains tasks, either all in the main.yml file, or grouped in files that can be included in the main file.

defaults/main.yml is the place to put the list of all variables for the role with a default value.

vars will hold files with variables definitions. Those differ from the defaults because of a much higher precedence (see below).

files is the directory where we'll put files to copy on hosts. They will be copied "as-is". When a role has multiple logical groups of tasks, it's best to create a sub-directory for each group that needs files. The name of files in these directories doesn't have to be the same as the destination name. Example :

copy:
  src: apt/jessie_backports_preferences
  dest: /etc/apt/apt.conf.d/backports

templates is the twin of files, but differs in that it contains files that can be pre-processed by the Jinja2 templating language. It can contain variables that will be extrapolated before copying the file to its destination.

handlers is the place to put special tasks that can be triggered by the notify argument of modules. For example an nginx -s reload command.

meta/main.yml contains … well … "meta" information. There we can define role dependencies, but also some "galaxy" information like the desired Ansible version, supported OS and distributions, a description, author/ownership, license…

tests and .travis.yml are here to help testing with a test matrix, a test inventory and a test playbook.

We can delete parts we don't need.

How much goes into a role

We create roles (instead of a plain tasks files) when it makes sense as a whole, and it is more that a series of tasks. It often has variables, files/templates, handlers…

Syntax

Pure YAML

It's possible to use a compact (Ansible specific) syntax,

- name: Add evomaintenance trap for '{{ user.name }}'
  lineinfile: state=present dest='/home/{{ user.name }}/.profile' insertafter=EOF line='trap "sudo /usr/share/scripts/evomaintenance.sh" 0'
  when: evomaintenance_script.stat.exists

but we prefer the pure-YAML syntax

- name: Add evomaintenance trap for '{{ user.name }}'
  lineinfile:
    state: present
    dest: '/home/{{ user.name }}/.profile'
    insertafter: EOF
    line: 'trap "sudo /usr/share/scripts/evomaintenance.sh" 0'
  when: evomaintenance_script.stat.exists

Here are some reasons :

  • when lines get long, it's easier to read ;
  • it's a pure YAML syntax, so there is no Ansible-specific preprocessing
  • … which means that IDE can show the proper syntax highlighting ;
  • each argument stands on its own.

Variables

defaults

When a role is using variables, they must be defined (for example in the defaults/main.yml) with a default value (possibly Null). That way, there will never be a "foo is undefined" situation.

If a variable can't have a default value, it must be marked as mandatory. Example :

- name: Setting default timezone
  lineinfile:
    line: "{{ evolinux_system_timezone | mandatory }}"

progressive specificity

In many roles, we use a progressive specificity pattern for some variables. The most common is for "alert_email" ; we want to have a default email address where all alerts or messages will be sent, but it can be customized globally, and also customized per task/role.

For the evolinux-base role we have those defaults :

  general_alert_email: "root@localhost"
  reboot_alert_email: Null
  log2mail_alert_email: Null
  raid_alert_email: Null

In the log2mail template, we set the email address like this :

mailto = {{ log2mail_alert_email or general_alert_email | mandatory }}

If nothing is customized, the mail will be sent to root@localhost, if general_alert_email is changed, it will be used, but if log2mail_alert_email is set to a non-null value, it will have precedence.

precedence

There are multiple places where we can define variables and there is a specific precedence order for the resolution. Here is the (ascending) order :

  • role defaults
  • inventory vars
  • inventory group_vars
  • inventory host_vars
  • playbook group_vars
  • playbook host_vars
  • host facts
  • play vars
  • play vars_prompt
  • play vars_files
  • registered vars
  • set_facts
  • role and include vars
  • block vars (only for tasks in block)
  • task vars (only for the task)
  • extra vars (always win precedence)

Configuration patterns

lineinfile vs. blockinfile vs. copy/template

When possible, we prefer using the lineinfile module to make very specific changes. If a regexp argument is specified, every line that matches the pattern will be updated. It's a good way to comment/uncomment variable, or add a piece inside a line.

When it's not possible (multi-line changes, for example), we can use the blockinfile module. It manages blocks of text with begin/end markers. The marker can be customized, mostly to use the proper comment syntax, but also to prevent collisions within a file.

If none of the previous can be used, we can use copy or template modules to copy an entire file.

defaults and custom files

We try not to alter configuration files managed by packages. It makes upgrading easier, so when a piece of software has a "foo.d" configuration directory, we add custom files there.

We usually put a z-evolinux-defaults with our core configuration. This file can be changed later via Ansible and must not be edited by hand. Example :

copy:
  src: evolinux-defaults.cnf
  dest: /etc/mysql/conf.d/z-evolinux-defaults.cnf
  force: yes

We also create a blank zzz-evolinux-custom file, with commented examples, to allow custom configuration that will never be reverted by Ansible. Example :

copy:
  src: evolinux-custom.cnf
  dest: /etc/mysql/conf.d/zzz-evolinux-custom.cnf
  force: no

The source file or template shouldn't to be prefixed for ordering (eg. z- or zzz-). It's the task's responsibility to choose how destination files must be ordered.

check mode

Ansible can run playbooks in "check mode" with the --check argument of ansible-playbook. It won't do any changing action but will report what would be changed or not.

When an action depends on a variable registered by a previous action (that performs no change, like a grep, stat…) we should add a check_mode: yes (always_run: yes in Ansible <= 2.1). Example :

- name: Check if Munin plugins exists
  stat:
    path: /etc/munin/plugins/
  register: munin_plugins_dir
  check_mode: no

- name: Get Munin plugin
  copy:
    src: munin/drbd-plugin
    dest: /etc/munin/plugins/drbd
    mode: "0755"
  when: munin_plugins_dir.stat.exists
  notify: restart munin-node

packages installation

When making a role or a task the necessary packages must be installed explicitly.

For example for the "mysql" role we obviously need the MySQL packages, but we also need the "apg" package to generate new passwords. This package is installed by "evolinux-base" but the "mysql" role can be executed on a fresh server.

merge arrays

Some roles need to have an array of values in a variable. For example, any roles use a list of trusted IP addresses (firewall, http auth, ssh whitelist…). It this array needs to include some values from a late file inclusion (from var_files, cli argument…) it becomes impossible to merge with another variable.

The workaround is to have 2 different default variables (eg. evolix_trusted_ips and additional_trusted_ips), witha default value of [] and merge them into the final variable. One of the variables (typically evolix_xxx) can be "hardcoded" in a vault and the final array remains extensible.

Example from the minifirewall role (with a final default value) :

evolix_trusted_ips: []
additional_trusted_ips: []
# Let's merge evolix_trusted_ips with additional_trusted_ips
# and default to ['0.0.0.0/0'] if the result is still empty
minifirewall_trusted_ips: "{{ evolix_trusted_ips | union(additional_trusted_ips) | unique | default(['0.0.0.0/0'], true) }}"

Caveats

Unix permissions must be written as String values

Many modules have a mode attribute to specify the permissions on the files or directories. We can use the symbolic notation (u+rwx or u=rw,g=r,o=r) or the octal notation (0755).

It is clearly documented that when using the octal notation a leading 0 must be present, but it is not clearly documented that we MUST use a String format, and not a Numeric format (or whatever Python types). Examples :

  • mode: 755 → Bad!
  • mode: 1777 → Bad!
  • mode: "0755" → Good
  • mode: "1777" → Good

This is most probably due to the way Python deals with numeric values and octal vs. decimal based integers. The String type guarantees that the proper value is used.