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"
→ Goodmode: "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.