Ansible: exploiting specific features

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 the vars file
  • vars: 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 and vars definitions),
    • exist in the target node (otherwise the shell command would raise an error)
  • 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