Skip to main content
RunBook Academy

AnsibleLI · Custom Modules and Tool SelectionWriting and shipping a module

Writing a module that behaves like a real one

Expert⏱ ~34 minbashpython3

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

Not yet marked complete on this device.

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.

Read-only / Safethe layout ansible-core discovers without configuration
/srv/ansible/
├── ansible.cfg
├── site.yml
├── library/
│   └── app_setting.py
├── module_utils/
│   └── settings_file.py
└── roles/
  └── app/
      └── library/
          └── app_role_only.py

A 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.

Read-only / Safehow a library/ module is named
$ ansible-doc -F | grep app_setting
ansible.legacy.app_setting             /srv/ansible/library/app_setting.py
ansible.legacy.app_setting_nocheck     /srv/ansible/library/app_setting_nocheck.py

ansible.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

Read-only / Safelibrary/app_setting.py
#!/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.

Read-only / Safea missing required argument
$ ansible -m app_setting -a 'path=/etc/app.conf key=max_workers' localhost --check
localhost | FAILED! => {
  "changed": false,
  "msg": "missing required arguments: value"
}
Read-only / Safean argument the spec does not declare
$ ansible -m app_setting -a 'path=/etc/app.conf key=max_workers value=8 colour=blue' localhost --check
localhost | 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:

Read-only / Safea module without supports_check_mode, run under --check
$ ansible -m app_setting_nocheck -a 'key=demo' localhost --check
localhost | SKIPPED

That 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.

Read-only / Safea failure that claims changed: true
$ ansible -m app_setting -a 'path=/proc/nope/app.conf key=k value=v' localhost
localhost | 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:

Read-only / Safethe corrected ending
    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

Read-only / Safea DOCUMENTATION block missing a required key
$ 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.

Read-only / Safemodule_utils/settings_file.py, imported by the module
# 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_settings

The 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

  1. Q1. A task passes `colour=blue` to a module whose argument_spec does not declare it. What happens?

  2. Q2. Which statements about the module execution path are correct? Select all that apply.

  3. Q3. A module that does not set supports_check_mode=True will still run normally when the playbook is invoked with --check.

  4. 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?

  5. 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.