Tips & tricks for Ansible

Ansible is a very powerful configuration management tool. It can be used to deploy settings and software to one or more (virtual) machines. Using a CM-tool ensures that every deployment is done in exactly the same way. This doesn’t only make your job as a system engineer a whole lot easier, it also results in a more stable environment since every machine is consistent and more predictable. I’m using Ansible intensively for quite some time now and found that it was time to write a post about some small, but usually hard to find or annoying to solve, problems or challenges.

Handlers

Handles will execute a certain action when a task ends with a status changed. Handlers are very powerful and useful since they aren’t executed every time they are called. Instead, the action required by the handler will be executed only once at the end of the play.

Keep all handlers in one file

Every role which you define in Ansible can contain a certain set of handlers. In a lot of cases, you end up with defining the same handler (for example: restart iptables or reload systemd) multiple times, for every role. Personally, I find it easier to have one file containing all my handlers and include that file from a specific role.

Create new roles with a main.yml in handlers/ containing the following:

[jendep@be-plw-ans-0001 roles]$ cat /etc/ansible/playbooks/roles/apache/handlers/main.yml
- include: ../../handlers.yml

Define all handlers in /etc/playbooks/roles/handlers.yml:

[jendep@be-plw-ans-0001 roles]$ cat /etc/ansible/playbooks/roles/handlers.yml
- name: reload postfix
  service: name=postfix state=restarted

- name: reload aliases
  command: newaliases

- name: reload exports
  command: exportfs -ra

- name: restart proftpd
  action: service name=proftpd state=restarted

- name: restart xinetd
  action: service name=xinetd state=restarted
...

Define a handler which suits different environments

When you’re creating roles and deploy them to different types of systems or different operating system versions, soon or later you’ll run into the problem that, for example, a name of a service is different on another distribution (for example: Apache on RHEL-based distributions is called http or the change to mariaDB in CentOS 7 from MySQL in CentOS 6). Theoretically, you could define each task which requires such kind of handler multiple times but that’s not really helpful for creating clean and clear roles.

To overcome this problem: call two handlers, one for each possible scenario, from where you would normally call your handler in your task:

- name: copy .my.cnf for root
template: src=my.cnf.j2 dest=/root/.my.cnf owner=root mode=0600
notify:
- restart mysqld
- restart mariadb

Define two handlers but put the condition to determine the action inside the handlers-file:

- name: restart mysqld
service: name=mysqld state=restarted
when: ansible_distribution_major_version =='6'

- name: restart mariadb
service: name=mariadb state=restarted
when: ansible_distribution_major_version =='7'

Variables

Variables are key in Ansible and when you know how to flexibly use variables, a whole lot of possibilities become available.

Set variables dynamically in a task

When you want to let a variable depend on another on or just on a fact, you can set the variable on the fly in the task using the variable. This can be done with set_fact:

- name: use Spacewalk in DMZ when needed
set_fact: spacewalk_server={{dmz_spacewalk_server}}
when: dmz

When you want to set a default for a variable, you can do this as follows:

- set_fact: ssh_permittunnel_value: 'yes'
when: ssh_permittunnel

- name: sshd - permit tunnel
lineinfile: dest=/etc/ssh/sshd_config
regexp="^PermitTunnel.*"
state=present
line="PermitTunnel {{ssh_permittunnel_value|default('no')}}"
notify: restart sshd

Specify multiple parameters in with_items

With_items for a task allows you to execute the task multiple times with different values. In some cases, you want to execute a task multiple times but use several different (but connected) values.

You can use the following example to specify multiple parameters in with_items:

- name: create users
user: name={{ item.name }} group={{ item.group }} state=present shell={{ item.shell }}
with_items:
- { name: 'user1', group: 'group1', shell: '/bin/bash' }
- { name: 'daemon-user', group: 'group2', shell: '/sbin/nologin' }

Define empty lists

As with the previous example, you could also have cases where you use with_items combined with a list defined in the inventory. If the list isn’t defined, you will receive an error when executing the playbook. To overcome this problem but still not execute the task, you can define an empty list in the inventory when needed.

Imagine the following task:

- name: create users
user: name={{ item }} state=present
with_items: user_list

When in a certain case, you do not need to create users, you can define the list as follows in the inventory

user_list: []

An alternative to this, is to check if the variable used in with_items is defined but that requires more work and makes the playbook less clear:

- name: create users
user: name={{ item }} state=present
with_items: user_list
when: user_list is defined

Substring a variabele in Ansible

It happens often that you only require a part of the value of a variable. In such case you can substring the value to get only parts of it. A use case could be version numbering. Sometimes a version is written as x.y and in another place as xy (for example package postgresql94-server install service postgresql-9.4.

The following task:

- debug: var="full variable {{ansible_os_family}}"
- debug: var="using substring the {{ansible_os_family[3:7]}} is {{ansible_os_family[:3]}} "

Gets you the following result:

full variable RedHat
using substring the Hat is Red

Calculate variables

Another manipulation with variables that’s often used is calculating variable values. As with most of the examples here, it’s quite easy to use but not always easy to find the correct syntax:

- name: configure memlock limits
lineinfile: dest=/etc/security/limits.conf
line="soft memlock {{ansible_memtotal_mb * 950000 }}"
state=present

Often, you get a result in a different format than you require. For example a float after dividing where you need a integer. You can round variables using round or convert them to int as follows:

- name: set number of hugepages
sysctl: name=vm.nr_hugepages value="{{(ansible_memtotal_mb/4)|int}}" state=present reload=no

Get the IP of a hostname inside a playbook

While this trick isn’t very Ansible-related it is something I use quite often. For some components, you require the IP-address of a hostname which you need to enter elsewhere, think of configuring a cluster or such. While I could define two variables myself, I find it better to define the hostname to use and search for the IP:

- name: get IP of testmachine
shell: "dig +short testmachine.test.dom| awk '{ print ; exit }'"
register: ip_test
always_run: yes
changed_when: False

debug: msg="ip of testmachine is {{ ip_test.stdout }}"

In the above example, the hostname is testmachine.test.dom but it could be a public hostname or a variable.

Use subitems in lists

One things which I find very powerful but at the same time also complex, is the use of nested lists. Complex nested lists can be used with with_subelements but the more simple version is by using a list that defines key-value items.

Imagine the following list:

- prod_list:
  - prod_name: "unknown product"
    prod_serial: "123456"
    prod_location: /opt/product1"
  - prod_name: "popular product"
    prod_serial: "987654"
    prod_location: /opt/product2"

The values in the list can be used in a with_items loop in a very user-friendly way:

debug: msg="product name {{item.prod_name}} serial {{item.prod_serial}} location {{item.prod_location}}"
with_items: prod_list

Templates

Templates are mainly used to create or modify files that have small parameters at the destination. Templates are written as Jinja2 and there is good documentation available. Nevertheless, here are some examples:

Looping a list

Using lists is also possible in templates. To do so, use the following example:

Imagine the list as I used in the example above. To use this list in a template:

{% for product in prod_list %}
[Product {{loop.index}}]
name={{product.prod_name}}
serial={{product.prod_serial}}
location={{product.prod_location}}
{% endfor %}

This will generate a file containing the following:

[Product 1]
name=unknown product
serial=123456
location=/opt/product1
[Product 2]
name=popular product
serial=987654
location=/opt/product2

{{loop.index}} gives you the index of the loop
{{loop.last}} can be used to test if this is the last item in the loop (for example to close some brackets).

To combine lists in a loop as above, you can use the following syntax:

{% for product in standard_products|list + extra_products|list %}

Various

Execute an action only when another action was executed

It happens often that something only needs to be done if a previous step was executed. For example, run a first initialization of product only when it was just installed.

This can be realized by using a a register and the .changed of that register.

A small example:

- name: register with spacewalk base-channel
rhn_register: server_url=http://{{ spacewalk_server }}/XMLRPC activationkey={{ spacewalk_key }}
register: spacewalkregistration
- name: clean up Yum

command: yum clean all
when: spacewalkregistration.changed

Manage known_hosts with Ansible

Doing things via Ansible often requires a different approach since you’re running everything as batch and can’t give input to questions or confirmations. To allow access to a host over SSH, it needs to be added to the known_hosts for the user accessing the system.

You can use the following example to maintain known_host-entries

- name: make sure known_hosts exists
file: path=/home/testuser/known_hosts owner=testuser group=testgroup mode=600 state=touch
changed_when: False

- name: check if hosts are already in known_hosts
shell: "ssh-keygen -f /home/testuser/known_hosts -F {{item}}"
with_items:
- testhost1.test.dom
- testhost2.test.dom
- 192.168.202.100
register: ssh_known_hosts
ignore_errors: True
always_run: yes
changed_when: False

- name: add hosts to known_hosts
shell: 'ssh-keyscan -H {{item.host}}>> /home/testuser/known_hosts'
with_items:
- { index: 0, host: testhost1.test.dom }
- { index: 1, host: testhost2.test.dom }
- { index: 2, host: 192.168.202.100 }
when: ssh_known_hosts.results[{{item.index}}].rc == 1

Execute a script and check the execution

A lot of functionality exists in the Ansible modules but sometimes you need to execute a small script on a target-machine in order to initiate something or just because it’s needed.

One way is to set the return code of the script (Ansible considers everything >0 as failed) but not all scripts handle return codes as they should.

With the following example, you can set the status of the task depending on the output of the script that was ran:

name: add this host to the list
shell: /tmp/testscript.sh 
register: testscript_result
changed_when: testscript_result.stdout.find("was added") == 1
failed_when: testscript_result.stdout.find("error adding") == 1

Open one or more ports with iptables

As most of you have noticed, there isn’t any Ansible module for iptables and there are no plans to make one. There are good reasons to not have such module but the work needs to be done so this is how I open up ports in the firewall with Ansible:

To open up one port:

- name: iptables - open TCP port 8080
lineinfile: dest=/etc/sysconfig/iptables
line="-A INPUT -p tcp -m state --state NEW -m tcp --dport 8080 -j ACCEPT"
regexp="(?=.*\btcp\b)(?=.*\bdport\b)(?=.*\b8080\b)"
state=present
insertbefore="(INPUT).*(REJECT)"
notify: reload iptables

To open up a list of ports:

- name: iptables - open ports
lineinfile: dest=/etc/sysconfig/iptables
line="-A INPUT -p {{item.protocol}} -m state --state NEW -m {{item.protocol}} --dport {{item.port}} -j ACCEPT"
regexp="(?=.*\b{{item.protocol}}\b)(?=.*\bdport\b)(?=.*\b{{item.port}}\b)"
state=present
insertbefore="(INPUT).*(REJECT)"
with_items:
- { protocol: "udp", port: "5404" }
- { protocol: "udp", port: "5405" }
- { protocol: "tcp", port: "2224" }
notify: reload iptables

The handler reload iptables is just a restart of iptables:

- name: reload iptables
  service: name=iptables state=reloaded

Install SELinux modules idempotent

Ansible comes with a module to control SELinux booleans but in some cases, a custom SELinux is required. Since there isn’t any module to do this (I should find some time and create a pull request for this), I use the following method to idem-potently add SELinux modules:

- name: SELinux - check loaded modules
shell: semodule -l
register: selinuxmodules
always_run: yes
changed_when: False

- name: copy SELinux modules for snmpd access to /tmp
action: copy src=snmpd_custom.pp dest=/tmp/{{item}}.pp owner=root mode=600
when: "selinuxmodules.stdout.find(snmpd_custom') == -1"

- name: install SELinux module for snmpd access
command: semodule -i /tmp/snmpd_custom.pp
when: "selinuxmodules.stdout.find(snmpd_custom') == -1"

Configure SELinux ports idempotent

Same thing as above is valid to allow non-standard ports in SELinux in a way that is safe to execute multiple times:

- name: check SELinux allowed ports for LDAP
shell: semanage port -l|egrep "^ldap_port_t"|egrep "\b2389\b"
register: selinuxldapports
always_run: yes
changed_when: False
ignore_errors: True

- name: configure SELinux to allow non-standard ldap-port
command: semanage port -a -t ldap_port_t -p tcp 2389
when: selinuxldapports.stdout.find("2389") == -1

Include specific task files

Altough this is well documented, I wanted to mention it here because it can come in very handy.

Imagine you want to create a role to deploy different Oracle Java versions. The role is defined in /etc/ansible/playbooks/java and there is a variable for every version to determine what to install.

One way would be to go trough all steps for every version and add a version-condition to all of them. Another way is to use includes:

The java-role will have a tasks/main.yml which is executed by default can contains statements like this:

- include: oracle_java_17.yml
  when: instal_oracle_java_17
- include: oracle_java_18.yml
  when: instal_oracle_java_18

This allows you to keep a neat main.yml and specific task-files in /tasks/oracle_java_1*.yml where you are more specific. The condition only needs to be written on the include statement and not for every statement in the included file.

I also use this for roles which differ a lot between different distributions.

Use host-spefic files or templates

As with the above example, this is also documented but very useful.

Image a situation where you need to copy a configuration file in XML that’s completely different for every host you’re deploying to. You can’t really define 100’s of lines as a variable for every line in the file and there is no way that you find enough similarities to create a template. For such case, it can be easy to create a file or folder with the name of the host to deploy to and use the name of the host in the path:

- name: configure license.xml
copy: src={{ ansible_hostname }}/license.xml dest=/opt/productX/license.xml owner=prod_X mode=644

Replace or remove a word in a line with lineinfile

This one is more of a regular expressions and syntax thing but took me some time to figure out correctly.

To replace words in an existing, unpredictable, line of a file, you can use a regex with backrefs.

Example: remove a kernel parameter

- name: GRUB (el7) - remove kernel parameter rhgb
lineinfile: dest=/etc/default/grub
state=present
regexp='^(.*)rhgb(.*)$'
line='\1\2'
backrefs=yes
notify: update grub2
when: ansible_distribution_major_version =='7'

Check if a file or directory exists

Checking if a file or directory exists sound a little weird but if you think a little further, it can come in very useful to check if something has been installed or if a certain script has already been executed. In other ways, to make non-repeatable scripts or commands idempotent.

Example: move a file if it exists

- name: check if testfile exists
stat: path=/var/lib/testfile
register: testfilestat

- name: move testfile to disable it
command: mv /var/lib/testfile /var/lib/testfile.disabled
when: testfilestat.stat.exists

stat.isdir can also be used to check if something is a directory

Example: check if a custom program is installed to prevent multiple executions of the installer (another way to do the same as above):

- name: check if product X is installed
  shell: 'if [[ -f /opt/productX/VERSION ]]; then echo "ok"; else echo "empty"; fi'
  register: installstatus
  always_run: yes
  changed_when: False

- name: unpack installation
  unarchive: src=productX.tar.gz dest=/opt/
  when: installstatus.stdout.find("ok") == -1

- name: run installation of productX
  shell: /opt/productX/install.sh>>/tmp/productx-install.log
  when: installstatus.stdout.find("ok") == -1

More tips & tricks

Hopefully the above examples and small explanations can help you to get even more out of Ansible. Feel free to mention common issues with or without their solution because I’m sure the above list is far from complete.

9 thoughts on “Tips & tricks for Ansible

  1. Great article and one of the few for Ansible tricks and tips. I like the iptables suggestion. I use UFW which is still super easy to handle with the ‘ufw’ module.

  2. You are looking to be an expert in ansible. Would you like to solve the following problem:
    – there is a role that should replase some text in a csv file
    – in defaults there are many variables(one for each column) that read a line from a csv file to replace values in particular columns, like that
    myvar: “{{ lookup( ‘csvfile’, ‘{{ hier }} file={{ path }}/{{ name }} delimiter=; col=1 encoding=utf-8’) }}”
    – the row where the text is to replace is erased in csv file
    – use lineinfile with the variables from defaults section to write a new line

    Problem:
    cvs file holds [] instead of content of the read variable, it is not the scope, the variable exists and has content, one can see the content with debug message – it is the entry from the read csv file.

    i have no ideas any more, that the problem could be, do you have one?

  3. Pingback: Ansible top tips – DevOps meets QA

Leave a Reply

Your email address will not be published. Required fields are marked *