trashnet.xyz


Avoid overcomplicated Ansible roles!

geerlingguy/ansible-role-mysql

When I have a new application that needs to be deployed (and eventually managed), one of the first things I do is check if an Ansible role already exists for it. I love writing Ansible myself, but why reinvent the wheel?

There’s just one thing. I’ve noticed that a lot of roles present you with a barrage of configuration variables that you then must cross-reference with the application’s documentation. I think there’s a simpler approach when writing roles like this.

The purpose of a public Ansible role

When writing a role for use by the general public, you are trying to accomplish the following things:

We want the deployment and configuration of our app to be consistent and idempotent. Sane defaults allow the app work out of the box for demonstration purposes. We may also want to automate maintenance tasks related to the app, like adding and removing a node from a cluster for example.

Intended audience and maintenance cost

It seems like a lot of roles end up doing backflips to prevent the user from having to write their own configuration files. This sucks for both the author and the user. The author has to disassemble every configuration option into a flat series of variables and then write the Jinja templating logic to reassemble them. The user has to then correlate those variables with the real options in the original documentation.

If you’re using Ansible to install an app that demands a lot of configuration like HashiCorp Vault or GitLab, you’re going to modify that configuration! Why would you want to write it through the layer of mystification that is Jinja?

Oh, and don’t forget: You would have to maintain this! As features are added, deprecated, changed, and removed, you would have to keep your template logic up to date. Why put that burden on yourself?

The simplest approach would be to allow the user to supply their own config files. All you would need to concern yourself with would be the deployment. While this might make sense in some cases, it’s not always the best option. Imagine that the app needs a configuration value that’s generated at install time like a shared encryption key. What’s the best way to tackle that?

For now, let’s talk about the structure of YAML itself.

Let the YAML structure work for you

I think the goal you should have in mind for this configuration-heavy Ansible role is not to inject values into a skeleton riddled with if statements, but to transform the structure of the YAML itself into the format you need.

Let’s start with a variable, app_config, whose underlying keys and values are exactly what the app uses in its config file:

site.yml

- hosts: app_runners
  roles:
    - role: app
      vars:
        app_config:
          node_id: "{{ ansible_hostname }}"
          network:
            bind_addr: "{{ ansible_default_ipv4.address }}"
            port: 8080
          ui:
            enabled: true
            port: 8081

If our app reads a JSON config file, then this is easy. Our template only has to look like this:

app/templates/config.json.j2

{{ app_config | to_nice_json }}

And we generate this:

config.json

{
    "network": {
        "bind_addr": "192.168.1.45",
        "port": 8080
    },
    "node_id": "node1",
    "ui": {
        "enabled": true,
        "port": 8081
    }
}

Easy! But what if our app expects this in an INI format? Then our var might look like this:

site.yml

- hosts: myserver
  roles:
    - role: app
      vars:
        app_config:
          - ids:
              node_id: node1
          - network:
              bind_addr: 192.168.1.45
              port: 8080
          - ui:
              enabled: true
              port: 8081

Each list item will be an INI heading and we just loop through the keys:

app/templates/config.ini.j2

{% for i in app_config %}
{% for heading, options in i.items() %}
[{{ heading }}]
{% for k, v in options.items() %}
{{ k }} = {{ v }}
{% endfor %}

{% endfor %}
{% endfor %}

Which gives us:

config.ini

[ids]
node_id = node1

[network]
bind_addr = 192.168.1.45
port = 8080

[ui]
enabled = true
port = 8081

Providing defaults and computed values

Now what about those sensible default values? And what about values that are populated during the play itself?

Our role contains a variable containing default values for the app, app_config_defaults. We will use the combine filter to merge it with our user-supplied app_config variable:

app/vars/main.yml

app_config_defaults:
  - ids:
      node_id: "{{ ansible_host }}"
  - network:
      bind_addr: "{{ ansible_default_ipv4.address }}"
      port: 8080
  - ui:
      enabled: true
      port: 8081

app_config_merged: "{{ app_config_defaults | combine(app_config, recursive=True) }}"

site.yml

- hosts: app_runners
  roles:
    - role: app
      vars:
        app_config:
          - network:
              port: 80
          - ui:
              enabled: false

Somewhere in the play, we add a new value:

app/tasks/secret.yml

- name: Generate key
  command: openssl rand -base64 12
  register: keygen

- set_fact:
    app_config:
      - secrets:
          key: "{{ keygen.stdout }}"

Other than changing our template variable to app_config_merged, we don’t have to modify anything else in there. Behold! The new INI section is added at runtime:

config.ini

[ids]
node_id = node1

[network]
bind_addr = 192.168.1.45
port = 80

[ui]
enabled = false
port = 8081

[secrets]
key = byZLIYeABjUIMiqT

Conclusion

I really believe that Ansible, like most things, works best when you keep it simple. Save yourself the headache of maintaining vast templates of Jinja logic. Trust that your users are able to figure out the app’s actual configuration options rather than your own translation of them!

I’ve added some roles to my Github for further demonstration:

And here are the templates in their own repo: jinja-configuration-xlators

stephen@trashnet.xyz