Writing a Custom Module in Python

K
Kai··5 min read

Ansible has thousands of modules, but sometimes none fits your need — calling an internal API, manipulating a peculiar file format. That's when you write your own module. This is the deep dive that closes the loop: recall Article 1, a module is just a Python program that takes parameters (JSON in) and returns results (JSON out), shipped to the host to run. Now we write one.

When you need to write a module

Before writing, make sure no module already exists (check ansible-doc -l, Galaxy). Write a module when:

  • You need to integrate an internal system (a company's own API).
  • The logic is complex and stitching together command/shell is both ugly and not idempotent (Article 5).
  • You want an idempotent operation for something that has no module yet.

If you only need to transform data in Jinja2 or fetch external data, a plugin (Article 12) may fit better than a module.

Structure of a module

A module is a Python script using the AnsibleModule helper class. Here's an idempotent module that ensures a file has given content:

#!/usr/bin/python
from ansible.module_utils.basic import AnsibleModule
import os

def run_module():
    # 1. Declare the parameters the module accepts
    module = AnsibleModule(
        argument_spec=dict(
            path=dict(type='str', required=True),
            content=dict(type='str', required=True),
        ),
        supports_check_mode=True,        # declare it supports --check
    )

    path = module.params['path']
    content = module.params['content']

    # 2. Read the CURRENT state
    current = None
    if os.path.exists(path):
        with open(path) as f:
            current = f.read()

    # 3. Compare with DESIRED → is a change needed?
    changed = current != content

    # 4. Only act if needed AND not in check mode
    if changed and not module.check_mode:
        with open(path, 'w') as f:
            f.write(content)

    # 5. Return result as JSON
    module.exit_json(changed=changed, path=path)

if __name__ == '__main__':
    run_module()

These five parts are the skeleton of every module:

  1. AnsibleModule(argument_spec=...) — declare the parameters the module accepts (type, required, default). Ansible validates the input parameters against this spec automatically.
  2. Read the current state on the host.
  3. Compare with the desired state → compute changed.
  4. Only act when needed and respect check_mode (change nothing under --check).
  5. exit_json(changed=..., ...) returns success (or fail_json(msg=...) on error).

Steps 2–4 are exactly the idempotency pattern from Article 5, now implemented by you.

Where to put the module

Ansible finds a custom module in this order: the library/ directory next to the playbook (found automatically), or a path declared in ansible.cfg:

[defaults]
library = ./library

Verified note: a playbook automatically finds library/ next to it, but ad-hoc commands do not — you must declare library = ./library in ansible.cfg (or the ANSIBLE_LIBRARY environment variable). Skip this and you'll hit Cannot resolve '<module>' to an action or module. At scale, packaging the module into a collection (Article 9) is the standard way to distribute.

Test the module

Put the file above at library/ensure_file.py, declare library in the cfg, then run it like any module:

# Run 1: file doesn't exist yet → must create it
ansible all -b -m ensure_file -a 'path=/tmp/custom.txt content=hello'
lab | CHANGED => { "changed": true, "path": "/tmp/custom.txt" }
# Run 2: content is already correct → do nothing
ansible all -b -m ensure_file -a 'path=/tmp/custom.txt content=hello'
lab | SUCCESS => { "changed": false, "path": "/tmp/custom.txt" }

Your module is idempotent: run 1 is CHANGED (creates the file), run 2 is SUCCESS/changed: false (already correct, does nothing) — and cat /tmp/custom.txt on the host returns hello. Because it declares supports_check_mode=True, it also respects --check (reports it would change without applying).

Recall Article 1: at run time, Ansible packages this ensure_file.py (along with module_utils) into an AnsiballZ, ships it over SSH to the host, runs it with the host's Python, and reads the exit_json JSON returned. The module you just wrote follows exactly that lifecycle.

exit_json and fail_json

Two ways to end a module:

module.exit_json(changed=True, path=path, msg="Wrote the file")   # success
module.fail_json(msg="Could not write: %s" % err)                 # error (task FAILED)

Every key you pass to exit_json becomes the task's return data — usable with register (Article 6). Convention: always return changed, and return additional useful info (path, results). On error, fail_json with a clear msg so users can troubleshoot.

Documentation block: for ansible-doc

A professional module ships with three documentation blocks (YAML strings) so ansible-doc <module> can read them and for testing:

DOCUMENTATION = r'''
module: ensure_file
short_description: Ensure a file has given content
options:
  path: { description: File path, required: true, type: str }
  content: { description: Desired content, required: true, type: str }
'''
EXAMPLES = r'''
- name: Write a config file
  ensure_file: { path: /etc/app.conf, content: "key=value" }
'''
RETURN = r'''
path: { description: File path, type: str, returned: always }
'''

DOCUMENTATION is also checked by ansible-lint/sanity tests (Articles 14, 15) — a module missing it is considered incomplete.

Tips for writing good modules

  • Idempotency is mandatory: always read-compare-then-change, don't act blindly.
  • Support check_mode: check module.check_mode before making a real change.
  • Validate input via argument_spec (type, required, choices, default) instead of validating yourself.
  • Report errors clearly via fail_json(msg=...).
  • Don't print to stdout other than the exit_json JSON — Ansible reads stdout as JSON (Article 1), and extra output corrupts it.

🧹 Cleanup

The sample module is in the nghiadaulau/ansible-series repo, directory 11-custom-module/library.

Wrap-up

Write your own module in Python with the AnsibleModule framework: declare parameters via argument_spec, read the current state, compare with the desired state to compute changed, act only when needed and respect check_mode, then return the result with exit_json/fail_json. Place the module in library/ (a playbook finds it automatically; ad-hoc needs library= declared in the cfg) or package it into a collection. The module you write follows exactly the AnsiballZ lifecycle from Article 1 — at this point the Ansible mechanism is complete. Include DOCUMENTATION to be professional.

A module extends execution on the host. Article 12 extends Ansible itself: filter, lookup, and callback plugins — extensibility in a different dimension.