Explicit merging of Ansible variables

15:32 reading time


Ansible inventory management is generally very simple and intuitive, but there are some infrastructure configurations that are difficult to express and configure with the built-in inventory and configuration variable functionality. Specifically, it is difficult to configure lists of a certain resource type (like users) across machines that are in different but overlapping groups (like QA environments overlapping with datacenters).

These situations are certainly possible to manage in Ansible, but we at Leapfrog wrote an Ansible plugin that offers a different way of declaring variables that we think can sometimes be more clear. That plugin is available here:

https://github.com/leapfrogonline/ansible-merge-vars

What follows is a detailed description of these hairy situations and how this plugin addresses them.

Background

Before we start, this article assumes that you have a working knowledge of using Ansible to provision infrastructure, specifically, the inventory and how variables are applied to hosts, and roles.

At Leapfrog, we use Ansible to provision our infrastructure. With Ansible, it is very easy to apply different configuration values to different infrastructure by defining variables that apply either to single hosts, or groups of hosts. These variables, the list of hosts, and the grouping of hosts, are collectively refered to as the ‘inventory.’ Hosts in the inventory can be put into groups, and these groups can also be put into groups. Variable values can be applied to hosts and groups. If a variable is applied to a group, then every host in that group or subgroup of that group will be able to refer to that variable and see its value during provisioning.

The happy path

In these diagrams, ovals are groups, and rectangles are individual machines. Note that Ansible provides a default group that will apply variables to all machines in the inventory; that group is represented by all. Let’s say that our Ansible inventory is laid out like this:

webserver1
webserver2
webserver3
appserver1
appserver2
appserver3

One datacenter

We’re going to write a role to add some users to the machines in our infrastructure.

We could declare a variable with some users in inventory/group_vars/all/users.yml:

all_users:
  - name: buddy_emmons
    comment: Big E
  - name: lloyd_green
    comment: The master

And write a users role with a tasks/main.yml file that looks like this:

- name: Add users
  user:
    name: "{{ user.name }}"
    comment: "{{ user.comment}}"
  with_items: "{{ all_users }}"

And a playbook that looks like this:

- name: Provision our servers
  hosts: all
  roles:
    - users

The path that’s a little slick from some rain a few hours ago

Now let’s say that we have some users that should only be provisioned on the app servers, and some users that should only be provisioned on the web servers. Buddy and Lloyd should still be provisioned on all of the machines. We could do this a few different ways, but let’s try keeping everything as explicit as possible.

First, we’ll make a couple of new groups in the inventory, so that it looks like this:

[webservers]
webserver1
webserver2
webserver3

[appservers]
appserver1
appserver2
appserver3

One datacenter, two groups

Next, let’s make separate variables for the unique users in each of our new webservers and appservers groups. Starting with inventory/group_vars/webservers/users.yml:

webserver_users:
  - name: john_hughey
    comment: Kroger neck master

Then in inventory/group_vars/appservers/users.yml:

appserver_users:
  - name: tom_brumley
    comment: Two strings and the truth

Unless we want to create a new users role for each group (which we don’t, because that would get really tedious as we add more groups), we can parameterize our existing users role, by using a variable that doesn’t exist anywhere in the inventory, and then passing that variable into the role in our playbooks. The tasks/main.yml file would look like this with the changed variable name:

- name: Add users
  user:
    name: "{{ user.name }}"
    comment: "{{ user.comment}}"
  with_items: "{{ passed_in_users }}"

And we’ll have a playbook for each group of servers. The web servers playbook would look like this:

- name: Provision our web servers
  hosts: webservers
  roles:
    - role: users
      passed_in_users: "{{ webserver_users }}"

The app servers playbook would look like this:

- name: Provision our app servers
  hosts: appservers
  roles:
    - role: users
      passed_in_users: "{{ appserver_users }}"

Cool! That’s not so bad…….. Oh phooey, we forgot about the users that should be on all the servers. Let’s add them to the passed in users in each playbook:

- name: Provision our web servers
  hosts: webservers
  roles:
    - role: users
      passed_in_users: "{{ all_users + webserver_users }}"
- name: Provision our app servers
  hosts: appservers
  roles:
    - role: users
      passed_in_users: "{{ all_users + appserver_users }}"

I’m sure we won’t forget them again…

The path that’s frozen over and blocked by fifteen feet of snow

Now let’s say that a couple of our app servers (but not all of them) and a couple of our web servers (but not all of them) need another user. We can put these machines in their own group, which would give us this:

[webservers]
webserver1
webserver2
webserver3

[appservers]
appserver1
appserver2
appserver3

[rabblerousers]
webserver1
webserver2
appserver1
appserver2

Rabble-rousers

We’ll start the same way, by putting the rabble-rousers in a file in a new group in the inventory, inventory/group_vars/rabblerousers/users.yml:

rabblerousers_users:
  - name: sneaky_pete_kleinow
    comment: The sneakiest burrito brother

But now we’re sort of stuck. We can’t just add rabblerousers_users to the passed_in_users variables that we pass to our roles in our webservers and appservers playbooks, because we don’t want Sneaky Pete causing trouble on all of them; just a couple of each. We could make another role just for Sneaky Pete, but again, the more this happens, the more one-off roles we’re going to have, and the more complicated things are going to get in the playbooks. We could make another two playbooks, one for the intersection of appservers and rabblerousers, and another for the intersection of webservers and rabblerousers, but yet again, the proliferation of such playbooks could rapidly become unmanageable.

What are we going to do?

The merge_vars plugin: de-icing and snow-blowing the path

At Leapfrog, we wrote an Action Plugin (an Ansible module that runs on the machine that is running Ansible rather than the machines that Ansible is provisioning) to merge variables based on a naming convention (that is enforced by the plugin), and put the merged value into a new variable that can be used in tasks or playbooks. Any variable that follows this naming convention and contains a list or dict value will get merged.

Showing this plugin in use for our example will hopefully make it more clear. Here’s our inventory again, as a reminder:

[webservers]
webserver1
webserver2
webserver3

[appservers]
appserver1
appserver2
appserver3

[rabblerousers]
webserver1
webserver2
appserver1
appserver2

Here’s what our other inventory files would look like using this plugin:

inventory/group_vars/all/users.yml:

all_users__to_merge:
  - name: buddy_emmons
    comment: Big E
  - name: lloyd_green
    comment: The master

inventory/group_vars/webservers/users.yml:

webservers_users__to_merge:
  - name: john_hughey
    comment: Kroger neck master

inventory/group_vars/appservers/users.yml:

appservers_users__to_merge:
  - name: tom_brumley
    comment: Two strings and the truth

inventory/group_vars/rabblerousers/users.yml:

rabblerousers_users__to_merge:
  - name: sneaky_pete_kleinow
    comment: The sneakiest burrito brother

And our tasks/main.yml in our users role would have one more task in it, explicitly describing the variables that we want to merge, and the variable to put the new value into:

- name: Merge user variables
  merge_vars:
    suffix_to_merge: users__to_merge
    merged_var_name: merged_users
    expected_type: 'list'

- name: Add users
  user:
    name: "{{ user.name }}"
    comment: "{{ user.comment}}"
  with_items: "{{ merged_users }}"  # The name of the variable we created in the previous task

Our playbooks get simpler too, and we don’t have to remember all of the things that we have to combine:

- name: Provision our web servers
  hosts: webservers
  roles:
    - role: users
- name: Provision our app servers
  hosts: appservers
  roles:
    - role: users

The two main things to notice are:

  1. The merge_vars task in the users role
  2. The users__to_merge suffixes on our variable names in our inventory files

The merge_vars plugin will automatically combine any variables in the inventory with that suffix that are in scope for the given host, and put a new variable with the merged value in scope for the play. In situations like this, it provides another method to parameterize configuration in a way that may be more clean and clear that what Ansible gives you out of the box.

For a more detailed description of how the plugin works, and if you want to use it for yourself, check it out here:

https://github.com/leapfrogonline/ansible-merge-vars

Happy merging!


Dfuchs90x90

Dan Fuchs
Director, Software Engineering