RL

Using Ansible handlers in loops

12.09.2020

Recently I have been dusting of my old ansible playbooks that I use to deploy personal stuff. Everything from side projects like feedhuddler.com to the unifi controller for my home network. In that setup I have a projects role that takes a list of projects and deploy them either running directly on the host with systemd services or docker containers. The systemd setup was using a task to restart the service after changing them. I wanted to change this to be done with a handler instead. However, handlers does not accept arguments. The posts describes a workaround for that problem, enabling dynamic use of handlers.

First let us look at what a handler is and how it differs from a task. The handlers are actions that can be triggered by other tasks. On a task you can define which handlers they should trigger. Very useful for things like "Configuration file was updated so we want to restart an application". A nice feature is that these handlers are triggered at the end of the playbook, and they only run once. Thus, if you have 5 tasks that triggers the same handler it will only run once at the end.

The problem at hand is that handlers does not accept any arguments. Thus, when a task have a loop going through a list of projects it is not straight forward how to be able to keep one handler for all these services. Below is an example setup, we will be looking at single file playbooks to make it easier to follow the changes that would usually be across different files in roles.

---
- hosts: all
  vars:
    projects:
      - name: super-project
        port: 4000
      - name: not-so-super-project
        port: 5000
  tasks:
    - name: 'Create systemd config for {{ item.name }}'
      loop: '{{ projects }}'
      template:
        src: web.service.jinja2
        dest: /etc/systemd/system/{{ item.name }}.service
        owner: root
        group: root
      notify:
        - reload systemd
        - 'restart {{ item.name }}'

  handlers:
    - name: reload systemd
      systemd:
        daemon_reload: yes
    - name: restart super-project
      service:
        name: '{{ item }}'
        state: restarted
    - name: restart not-so-super-project
      service:
        name: '{{ item }}'
        state: restarted

This works, but when adding a new project it is necessary to add a new handler. This is not the end of the world. However, I do not touch this code very often and when I do it is usually because I need to change the configuration for a service or add a new service. Thus, it would be nice to be able to that without editing the role every time.

There is another feature in ansible we can use to work around the fact that handlers does not support arguments. Each task has the ability to store the result in a variable, and if the task has a loop that variable will be a list of these results. This is done by adding register: variable_name to the task. Even though handlers does not work with arguments, it has access to the global variable scope. This means we can make a handler looping over the result and restart the services. See the highlighted changes below.

---
- hosts: all
  vars:
    projects:
      - name: super-project
        port: 4000
      - name: not-so-super-project
        port: 5000
  tasks:
    - name: Create systemd config for projects
      loop: '{{ projects }}'
      template:
        src: web.service.jinja2
        dest: /etc/systemd/system/{{ item.name }}.service
        owner: root
        group: root
      register: systemd_projects      notify:
        - reload systemd
        - restart systemd projects
  handlers:
    - name: reload systemd
      systemd:
        daemon_reload: yes
    - name: restart systemd projects      loop: "{{ systemd_projects.results | selectattr('changed', 'equalto', true) | map(attribute='item') | map(attribute='name') | list }}"      service:        name: '{{ item }}'        state: restarted

The playbook now have one handler for restarting the projects: restart systemd projects. Let's have a closer look at the loop for that handler. systemd_projects.results is the result list from the task above creating the systemd service files. selectattr('changed', 'equalto', true) is a filter that will select only the items in the list that has a property changed set to true. The two next filters map(attribute='item') | map(attribute='name') maps the items to certain attributes. The last filter list converts a generator into a list.

This means that this list

[
  { "changed": true, "item": { "name": "super-project", "port": 4000 } },
  { "changed": false, "item": { "name": "not-so-super-project", "port": 5000 } }
]

would turn into

["super-project"]

which in turn would result in the handler running

service:
  name: super-project
  state: restarted

In other words, we have a dynamic handler that can restart services based on changes so that we can focus on only the configuration whenever there is a new side-project to deploy.