Ansible provides a good balance between features and lower complexity, compared to other deployment configuration tools like Puppet. The Ansible docs are typically a good source for information, yet some information or examples on specific (common) procedures seems to be missing. The following describes some of them.
Common
The following two files are used across all the explained playbooks and tasks below.
The hosts
files would provide the following IPs to access each group:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[all:vars]
ansible_connection=ssh
ansible_port=22
ansible_user=userC
ansible_ssh_pass=passC
[core]
172.16.0.100
172.16.0.101
[edge]
10.0.0.100
10.0.0.101
10.0.0.102
Some variables are used to complement the operations. These can be stored in (for instance) the all
file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# General variables
---
nodes:
# Extend with more groups and interfaces as needed
# Note: MGMT IP required. It must match with that on the "hosts" file
core:
access:
user: userC
pass: passC
group: groupC
core1:
ip:
- mgmt: 172.16.0.100
- eth1: 172.16.23.100
core2:
ip:
- mgmt: 172.16.0.101
- eth1: 172.16.23.101
edge:
access:
user: userE
pass: passE
group: groupE
edge1:
ip:
- mgmt: 10.0.0.100
- eth1: 10.0.10.100
- eth2: 10.0.20.100
edge2:
ip:
- mgmt: 10.0.0.101
- eth1: 10.0.10.101
- eth2: 10.0.20.101
edge3:
ip:
- mgmt: 10.0.0.102
- eth1: 10.0.10.102
- eth2: 10.0.20.102
Tasks
Templating a task
Some tasks perform the same steps, but with minor differences based on a specific types of nodes. In such a case, the task can be templated to serve both groups of nodes, and in so minimising the code. The differences would then be provided as variables to the templated task.
Assume you want to change the hostname in different groups of nodes; e.g. “core” and “edge”. Each of them will be named after its type, and of course targeting different hosts.
The template (host-config-name.yml
):
1
2
3
4
5
6
7
8
9
10
# Define hostname
---
# Required values:
# - type: kind of nodes
- name: "Define name per {{ type }} node"
hostname:
name: "{{ type }}"
become: yes
The tasks performing each configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Define hostname per group
---
- name: Hostname configuration for core nodes
hosts: core
tasks:
- import_tasks: host-config-name.yml
vars:
type: "core"
- name: Hostname configuration for edge nodes
hosts: edge
tasks:
- import_tasks: host-config-name.yml
vars:
type: "edge"
Custom filters
There is a great tutorial on this site.
Basically, you can define Python scripts implementing specific methods within a FilterModule
class. These should be located under a folder called filter_plugins
, right at the root of your Ansible project. Note that you can place any number of scripts inside the filter_plugins
– all of them will be loaded. Some examples can be found in the site above, here or below.
In this example, the inventory_hostname
variable stores the value for the node currently accessed by Ansible.
1
2
3
4
5
6
7
8
9
10
11
import re
class FilterModule(object):
def filters(self):
return {
"ip_get_3_first_octets": self.ip_get_3_first_octets,
}
def ip_get_3_first_octets(self, data):
reg_exp = "\\b(\d{1,3}.)(\d{1,3}.)(\d{1,3}.)(\d{1,3})\\b"
return re.sub(reg_exp, "\\1\\2\\3*", data)
1
2
3
4
5
6
7
8
- name: Fetch the first 3 octets of each IP
hosts: edge
tasks:
- debug:
msg: "Network for current IP: {{ octet3 }}"
vars:
octet3: {{ target | ip_get_3_first_octets }}
target: "{{ inventory_hostname }}"
Alternatively, sometimes you can achieve the same functionality (considerably more verbose – and mind the extra escaping) by parsing specific values using the Jinja templating language, inside the Ansible playbook.
1
2
3
4
5
6
7
8
- name: Fetch the first 3 octets of each IP
hosts: edge
tasks:
- debug:
msg: "Network for current IP: {{ octet3 }}"
vars:
octet3: {{ target | regex_replace('\\b(\\d{1,3}.)(\\d{1,3}.)(\\d{1,3}.)(\\d{1,3})\\b', '\\1\\2\\3*') }}
target: "{{ inventory_hostname }}"
Filtering nodes
If you want to configure the network interfaces of a given set of hosts, one way is to construct a structure that contains all interfaces per host, which would later be iterated.
Note that this means that Ansible will perform a double loop, first on the nodes defined in the hosts
files, secondly on the interfaces defined in such structure. In practice, this means that it would attempt to configure the interface of each of the nodes with each of the IPs defined in the variables. Thus, filtering is needed to configure the specific interface per host.
The following example shows one way to do it (of course, nmcli module is available to better generalise this operation; but that would introduce the dependency for the dbus-python
package).
The template (iface-ifconfig-ip.yml
) uses templating, filters and loops over subelements, typically dictionaries.
The templated task would use:
with_subelements
: to iterate over each IP (the specific key “ip
”) defined in thevars
filevars
: to transform data and store the cumulative changes into variables; as well as some checks (e.g. if the interface to be configure exists in the node to target)when
: to define assumptions under which the task should run. Here, the interface should- not be named “mgmt” (as this is used to map data between
hosts
andvars
definitions), - exist in the target node (otherwise the shell command would raise an error)
- not be named “mgmt” (as this is used to map data between
ignore_errors
: allow other tasks to continue if this fails (should be restricted to non-critical configurations)shell
: provide the specific command to the shell (note the differences between shell, command and script – pick the one most suited to your needs)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Define IP address per interface
---
# Required values:
# - nodes: dictionary with interface and ip per host
# - type: kind of nodes
- name: "Configure interface per {{ type }} node"
shell:
ip addr flush dev {{ iface_name }} && ip addr add {{ iface_ip_net }} dev {{ iface_name }}
args:
executable: /bin/bash
ignore_errors: True
when: (iface_name | search("eth")) and (iface_in_target_node)
vars:
iface_in_target_node: "{{ nodes | iface_in_target_node(target, iface_ip) }}"
target_ips: "{{ hostvars[inventory_hostname] }}"
target: "{{ inventory_hostname | ip_strip_subnet }}"
iface_name: "{{ item.1 | get_dict_key }}"
iface_ip: "{{ iface_ip_net | ip_strip_subnet }}"
iface_ip_net: "{{ item.1 | get_dict_value }}"
ifaces: "{{ item }}"
with_subelements:
- "{{ nodes }}"
- ip
become: yes
The tasks performing each configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Define hostname per group
---
- hosts: core
tasks:
- import_tasks: iface-ifconfig-ip.yml
vars:
type: "core"
nodes: "{{ nodes.core }}"
- hosts: edge
tasks:
- import_tasks: iface-ifconfig-ip.yml
vars:
type: "edge"
nodes: "{{ nodes.edge }}"
Find and remove files
This is handy when attempting to produce idempotent scripts; that is, consecutive runs would produce similar behaviour and not affect other runs.
For instance, an installation process may generate some files, which might conflict in some way with posterior installations. A possible way of dealing with this is identifying all such files and removing these prior to any installation. The combination of find and file can help on this.
The task would find first for files matching the provided pattern(s), separated by comma (for instance prefixes, but could be more powerful regular expressions) and under the specific path. The result, a list of files, would be registered into a new variable. This can be used later on to ensure each of these files is absent (and if not, remove them).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- hosts: core
tasks:
- name: Find previous installation temporary files
find:
paths: "/tmp"
patterns: "prefix1*,prefix2*"
recurse: yes
file_type: any
register: install_prev_tmp_files
- name: Clear previous installation temporary files
file:
path: "{{ item.path }}"
state: absent
with_items:
- "{{ install_prev_tmp_files.files }}"
Note that normal locations should not be mixed with find
results inside the same with_items
directive; as normal locations would be accessed each of them through {{ item }}
, not {{ item.path }}
.
SSH from a node
While accessing a node, it may be necessary that this node connects to another one; for instance to share its public key and be accessible in the future. One possible way to do is this, which is adapted in the example below.
Here, an SSH connection is performed towards a specific “master” node from the “core” group (for instance, the 1st element defined in the hosts
file). The first task would fetch its public key and register to a variable. Then, the second task would iterate over each “core” node (as defined in the all
variables file), and use the authorized_key module to authorise the SSH key into all “core” nodes.
Note the usage of the delegate_to
directive. This is the magic effectively applying the command on another remote node.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- hosts: core[0]
tasks:
- name: Fetch master node public key
shell: cat ~/.ssh/id_rsa.pub
register: ssh_masternode_key
become_user: "{{ nodes.core.access.user }}"
become: yes
- name: Insert SSH keys on all core nodes
authorized_key:
state: present
user: "{{ nodes.core.access.user }}"
key: "{{ item[0] }}"
delegate_to: "{{ item[1] }}"
vars:
target_ips: "{{ hostvars[inventory_hostname] }}"
with_nested:
- "{{ ssh_masternode_key.stdout }}"
- "{{ nodes.edge | ips_from_nodes(target_ips) }}"
become_user: "{{ nodes.core.access.user }}"
become: yes
Override log-in
Assuming there is a task that requires a specific log-in, different to that defined in hosts
; it is possible to override its value directly within the task. This applies to any other value defined there.
In this example, all assumptions are made here; for instance that all “edge” nodes share the same credentials ({{ nodes.edge.access.user }}
and {{ nodes.edge.access.pass }}
) and would be access through them.
1
2
3
4
5
6
7
8
9
10
11
12
13
- hosts: edge
tasks:
- name: Define edge nodes' group as sudo-less
lineinfile:
dest: /etc/sudoers
state: present
regexp: "^%{{ nodes.edge.access.group }}"
line: "%{{ nodes.edge.access.group }} ALL=(ALL) NOPASSWD: ALL"
validate: "visudo -cf %s"
vars:
ansible_user: "{{ nodes.edge.access.user }}"
ansible_ssh_pass: "{{ nodes.edge.access.pass }}"
become: yes
Roles with params
Roughly, a role groups functionality (several logic and data). To do so, it is based on a specific structure for directories and files. This can simplify the syntax for the playbooks as well.
In some cases, a role may expect parameters. You can do so following one of the methods below (from older to newer):
1
2
3
4
5
6
7
8
9
10
11
12
- hosts: edge
# Method 1
roles:
- { role: config_ifaces, type: edge, nodes: "{{ nodes.edge }}" }
- { role: config_hostname, type: edge, nodes: "{{ nodes.edge }}" }
# Method 2
tasks:
- include_role:
name: config_firewall
vars:
type: edge
nodes: "{{ nodes.edge }}"
In specific cases where the same parameters are used, these could be shared as facts, as these will be available to subsequent plays during the run of a playbook. Note that facts are typically used to store information for specific hosts, though.
1
2
3
4
5
6
7
8
9
10
- hosts: edge
pre_tasks:
- set_fact:
nodes: "{{ nodes.edge }}"
- set_fact:
type: edge
roles:
- config_ifaces
- config_hostname
- config_firewall
Remote env vars
A particular deployment may leverage on a number of environment variables to work properly. When such command is ran remotely (either with Ansible’s shell, command or script; or with plain SSH), the operation will likely fail. This is not a particular issue of Ansible.
Instead, to solve it, my recommendation would be to follow this approach and define any “source” command to load all environment variables before the following check in ~/.bashrc
:
1
2
# If not running interactively, don't do anything
[ -z "$PS1" ] && return
A possible option is to use lineinfile
or blockinfile
to insert this in the ~/.bashrc
file of the given user that will run a command relying on environment variables.
1
2
3
4
5
6
7
8
9
10
# Line must be added before non-interactivity SSH checks block any other command
- name: Define specific environment variables or source them
blockinfile:
dest: ~/.bashrc
state: present
insertbefore: BOF
content: |
# Source deployment env before non-interactivity SSH checks block it (e.g., at beginning of file)
. ~/tool/env/variables