Writing a Custom Module in Python
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/shellis 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:
AnsibleModule(argument_spec=...)— declare the parameters the module accepts (type, required, default). Ansible validates the input parameters against this spec automatically.- Read the current state on the host.
- Compare with the desired state → compute
changed. - Only act when needed and respect
check_mode(change nothing under--check). exit_json(changed=..., ...)returns success (orfail_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 declarelibrary = ./libraryinansible.cfg(or theANSIBLE_LIBRARYenvironment variable). Skip this and you'll hitCannot 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_modebefore 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_jsonJSON — 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.