Writing Ansible modules: when and why?

November 23, 2022 

What are Ansible modules?

Ansible modules provide the infrastructure as code building blocks for your Ansible roles, plays and playbooks. Modules manage things such as packages, files and services. The scope of a module is typically quite narrow: it does one thing but attempts to do it well. Writing custom Ansible modules is not particularly difficult. The first step is to solve the problem with raw Python, then you can convert that Python code to an Ansible module

Some problems can't be solved elegantly with existing modules

The default modules get you quite far. However, occasionally you may end up with tasks that are quite difficult to do with Ansible yaml code. In these cases the Ansible code you write becomes very ugly or very difficult to understand, or both. Writing custom Ansible modules can greatly simplify things if this happens.

Example of modifying trivial JSON with raw Ansible

Here is a an example of how to modify a JSON file with Ansible. The file looks like this:

{
  "alt_domains": ["foo.example.org", "bar.example.org"]
}

What Ansible needs to do is add entries to and remove entries from the alt_domains list. The task sounds simple, but the solution in raw Ansible is very ugly:

- name: load current alt_domains file
  include_vars:
    file: "{{ alt_domains_file }}"
    name: alt_domains
- name: set default value for alt_domain_present
  ansible.builtin.set_fact:
    alt_domain_present: false
# The lookup returns data in this format: {'key': 'alt_domains', 'value': ['foobar.example.org', 'foobar.example.org']}
- name: check if current alt_domain already exists in alt_domains
  ansible.builtin.set_fact:
    alt_domain_present: true
  loop: "{{ query('ansible.builtin.dict', alt_domains) }}"
  when: alt_domain in item.value
- name: add alt_domain to alt_domains
  set_fact:
    alt_domains: "{{ alt_domains | default({}) | combine({\"alt_domains\": [\"{{ alt_domain | mandatory }}\"]}, list_merge=\"append\") }}"

Most would probably agree that the code above is already very nasty. That said, it does yet even handle removal of entries from the list or writing the results back to disk. If you had to modify non-trivial JSON files using code like above would make your head explode. There may be other ways to solve this particular problem in raw Ansible. If there are, I was unable to find any easily.

The solution: writing custom Ansible modules

With Ansible you occasionally end up in a hairy situation where you find yourself hacking your way through a problem. It is in those cases where writing a custom Ansible module probably makes most sense. To illustrate the point here's a rudimentary but fully functional implementation for managing alt_domains file such as above:

#!/usr/bin/python
import json

from ansible.module_utils.basic import AnsibleModule

def read_config(module):
  try:
    with open(module.params.get('path'), 'r') as alt_domains_file:
      have = json.load(alt_domains_file)
  except FileNotFoundError:
    have = { "alt_domains": [] }

  return have

def write_config(module, have):
  with open(module.params.get('path'), 'w') as alt_domains_file:
    json.dump(have, alt_domains_file, indent=4, sort_keys=True)
    alt_domains_file.write("\n")

def run_module():
  module_args = dict(
    domain=dict(type='str', required=True),
    path=dict(type='str', required=True),
    state=dict(type='str', require=True, choices=['present', 'absent'])
  )

  result = dict(
    changed=False
  )

  module = AnsibleModule(
    argument_spec=module_args,
    supports_check_mode=True
  )

  if module.check_mode:
      module.exit_json(**result)

  have = read_config(module)
  want = module.params.get('domain')
  state = module.params.get('state')

  if state == 'present' and want in have['alt_domains']:
    result.update(changed=False)
  elif state == 'present' and not (want in have['alt_domains']):
    result.update(changed=True)
    have['alt_domains'].append(want)
    write_config(module, have)
  elif state == 'absent' and want in have['alt_domains']:
    result.update(changed=True)
    have['alt_domains'].remove(want)
    write_config(module, have)
  elif state == 'absent' and not (want in have['alt_domains']):
    result.update(changed=False)
  else:
    module.fail_json(msg="Unhandled exception: domain == %s, state == %s!" % (want, state))

  module.exit_json(**result)

def main():
  run_module()

if __name__ == '__main__':
  main()

This Python code could use some polishing (e.g. proper check_mode support). Yet it is still a lot more readable and understandable than the hackish raw Ansible yaml implementation would be. You also get variable validation for free without having to resort to strategically places ansible.builtin.assert calls.

Summary: do not be afraid of writing Ansible modules

Sometimes you may find yourself in a world of hurt while solving seemingly easy problem with raw Ansible yaml code. This is when you should stop and consider writing and Ansible module instead. Writing a custom Ansible module can make your code much more understandable, flexible and of better quality.

More about Ansible quality assurance from Puppeteers

Samuli Seppänen
Samuli Seppänen
Author archive
menucross-circle