AnsibleLI · Custom Modules and Tool SelectionWriting and shipping a module
Writing a module that behaves like a real one
What you'll learn
- Write a module that ansible-core loads, validates arguments for, and runs in check mode
- Declare an argument_spec with types, required flags and no_log
- Return results through exit_json and fail_json without leaking secrets
- Share logic through module_utils rather than copying it between modules
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Lesson 1 argued that most custom modules should not exist. This one assumes you cleared that bar and now have to write something a colleague can trust.
The running example is deliberately small: a module that manages one key
in a flat key=value settings file. Small enough to read in full, and
big enough to exercise every part of the API — argument types, a secret
argument, idempotency, check mode, a failure path, and documentation.
Every output in this lesson was produced by running it against
ansible-core 2.21.3.
Where the file goes
A module in library/ next to your playbook is found automatically.
That is the fastest path from idea to working code, and it is where
every module starts.
/srv/ansible/
├── ansible.cfg
├── site.yml
├── library/
│ └── app_setting.py
├── module_utils/
│ └── settings_file.py
└── roles/
└── app/
└── library/
└── app_role_only.pyA library/ inside a role is also searched, but only while that role is
running — which is either exactly what you want or a confusing scoping
surprise, depending on whether you knew.
$ ansible-doc -F | grep app_settingansible.legacy.app_setting /srv/ansible/library/app_setting.py
ansible.legacy.app_setting_nocheck /srv/ansible/library/app_setting_nocheck.pyansible.legacy is the namespace that includes library/ modules. It
matters for one reason: a library/ module whose filename matches a
builtin shadows the builtin for any task written with the short
name. Naming a local module copy.py is a way to break a repository
subtly. Prefix local modules with something of your own.
The module, in full
#!/usr/bin/python
from __future__ import annotations
DOCUMENTATION = r'''
---
module: app_setting
short_description: Manage one key in a simple key=value settings file
description:
- Sets a single key in a flat key=value settings file.
- Reports changed only when the stored value differs from the requested one.
author:
- Example Team (@example)
options:
path:
description: Path to the settings file.
required: true
type: path
key:
description: Setting name.
required: true
type: str
value:
description: Setting value.
required: true
type: str
api_token:
description: Token used to notify the application. Never logged.
required: false
type: str
'''
RETURN = r'''
previous_value:
description: The value before the change, or null if the key was absent.
returned: always
type: str
'''
from ansible.module_utils.basic import AnsibleModule
def read_settings(path):
settings = {}
try:
with open(path, 'r', encoding='utf-8') as fh:
for line in fh:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
settings[k.strip()] = v.strip()
except FileNotFoundError:
pass
return settings
def write_settings(path, settings):
with open(path, 'w', encoding='utf-8') as fh:
for k in sorted(settings):
fh.write('%s=%s\n' % (k, settings[k]))
def main():
module = AnsibleModule(
argument_spec=dict(
path=dict(type='path', required=True),
key=dict(type='str', required=True),
value=dict(type='str', required=True),
api_token=dict(type='str', required=False, no_log=True),
),
supports_check_mode=True,
)
path = module.params['path']
key = module.params['key']
value = module.params['value']
settings = read_settings(path)
previous = settings.get(key)
result = dict(changed=False, previous_value=previous)
if previous == value:
module.exit_json(**result)
result['changed'] = True
if module.check_mode:
module.exit_json(**result)
settings[key] = value
try:
write_settings(path, settings)
except OSError as exc:
module.fail_json(msg='cannot write %s: %s' % (path, exc), **result)
module.exit_json(**result)
if __name__ == '__main__':
main()The four parts that matter
argument_spec — the interface, and the validation
argument_spec is not documentation. It is executable validation that
runs before your code does, on the managed node.
$ ansible -m app_setting -a 'path=/etc/app.conf key=max_workers' localhost --checklocalhost | FAILED! => {
"changed": false,
"msg": "missing required arguments: value"
}$ ansible -m app_setting -a 'path=/etc/app.conf key=max_workers value=8 colour=blue' localhost --checklocalhost | FAILED! => {
"changed": false,
"msg": "Unsupported parameters for (app_setting) module: colour. Supported parameters include: api_token, key, path, value."
}That second behaviour is worth appreciating. A module whose
argument_spec is complete rejects typos at the boundary. A module that
reads raw parameters instead would ignore colour and quietly do the
wrong thing.
The type field does real work too. type='path' expands ~ and
resolves the value as a filesystem path; type='int' converts and
rejects non-numbers; type='bool' accepts the YAML truthy forms
consistently so you never write your own string comparison. The
available types include str, int, float, bool, list, dict,
path, raw, jsonarg, json, bytes and bits.
Beyond types, argument_spec supports choices for enumerations,
default, aliases, and the cross-argument relationships declared on
AnsibleModule itself: mutually_exclusive, required_together,
required_one_of and required_if. Using them means the error messages
your users see are written by ansible-core and are consistent with
every other module they have used.
no_log=True — the one flag with a security consequence
supports_check_mode=True — a promise, enforced by nothing
Declaring it tells ansible-core that this module can be run under
--check and will not change anything. Core takes the declaration at
face value and hands the module a check-mode run.
If you do not declare it, core refuses to run the module under --check
at all:
$ ansible -m app_setting_nocheck -a 'key=demo' localhost --checklocalhost | SKIPPEDThat is the honest default and it is why supports_check_mode is
opt-in. Declaring it while still writing the change is the defect that
Part LII lesson 1 catalogues and the break/fix scenario for this part is
built on — a “dry run” that modified forty hosts. The structural rule
that prevents it is visible in the example module: the
module.check_mode branch exits before the first line that writes
anything, and there is exactly one such line.
exit_json and fail_json — the only ways out
Both serialise a dictionary to stdout as a single JSON object and
terminate the process. exit_json exits successfully; fail_json
requires msg and marks the task failed.
Do not return from main(), do not sys.exit(), and do not print().
Anything else on stdout corrupts the result document.
$ ansible -m app_setting -a 'path=/proc/nope/app.conf key=k value=v' localhostlocalhost | FAILED! => {
"changed": true,
"msg": "cannot write /proc/nope/app.conf: [Errno 2] No such file or directory: '/proc/nope/app.conf'",
"previous_value": null
}This is a genuine bug in the example, left in deliberately because it is
the single commonest defect in hand-written modules and it is invisible
until you look. changed: true on a task that changed nothing will fire
handlers on hosts where the operation failed, and will contaminate the
drift signal from Part XXXVI.
The fix is to set changed only after the operation that justifies it
succeeds:
if module.check_mode:
module.exit_json(changed=True, previous_value=previous)
settings[key] = value
try:
write_settings(path, settings)
except OSError as exc:
module.fail_json(msg='cannot write %s: %s' % (path, exc),
changed=False, previous_value=previous)
module.exit_json(changed=True, previous_value=previous)Lesson 3 turns this into a reviewable contract.
Documentation is executable, in the sense that it can fail
$ ansible-doc app_setting[ERROR]: Unable to retrieve documentation from 'app_setting': 'description'Adding the top-level description list fixes it, and ansible-doc then
renders the options, types, author and return values exactly as it does
for a collection module. The sanity tests in lesson 4 check this
automatically, which is the reason to run them.
Sharing logic: module_utils
Two modules that both parse the settings file should not both contain
read_settings. Put shared code in module_utils/ and import it — the
AnsiballZ packer walks the imports and includes the file in the payload
it ships.
# module_utils/settings_file.py
def read_settings(path):
settings = {}
try:
with open(path, 'r', encoding='utf-8') as fh:
for line in fh:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
settings[k.strip()] = v.strip()
except FileNotFoundError:
pass
return settings
# library/app_setting.py
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.settings_file import read_settingsThe constraint that catches people: module_utils code runs on the
managed node, with the remote Python. It cannot import anything from the
controller’s environment, and it cannot assume a library is installed
unless the module documents that requirement.
Knowledge check
Knowledge check · 5 questions
Q1. A task passes `colour=blue` to a module whose argument_spec does not declare it. What happens?
Q2. Which statements about the module execution path are correct? Select all that apply.
Q3. A module that does not set supports_check_mode=True will still run normally when the playbook is invoked with --check.
Q4. A module sets result["changed"] = True before attempting a write, then passes that dict to fail_json when the write raises. What is the consequence?
Q5. Why is naming a local module in library/ after a builtin, such as copy.py, a problem?
Passing score: 75%. Answers are checked in this browser.