AnsibleLI · Custom Modules and Tool SelectionWriting and shipping a module
Testing it, then shipping it properly
What you'll learn
- Execute a module directly with a JSON args file for fast iteration
- Run a module ad-hoc through ansible-core without packaging it
- Run ansible-test sanity against a module and act on what it reports
- Package a module into a versioned private collection and explain why library/ does not scale
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
A module that has only ever been tested by running the playbook is a module whose feedback loop is thirty seconds long, involves a managed node, and reports errors as JSON parse failures.
There is a much shorter loop, and there is a test suite that checks several of lesson 3’s contract items automatically. This lesson covers both, then the packaging step that stops the module becoming three divergent copies.
Loop 1: run the module directly
AnsibleModule reads its parameters from a JSON file given as the first
argument, so the module can be executed as an ordinary Python script
with no ansible-core invocation at all.
{
"ANSIBLE_MODULE_ARGS": {
"path": "/tmp/app.conf",
"key": "max_workers",
"value": "8",
"api_token": "REPLACE_ME"
}
}$ python library/app_setting.py args.json; python library/app_setting.py args.json{"changed": true, "previous_value": null}
{"changed": false, "previous_value": "8"}That is the fastest possible check of contract item 1, and it is
immediate: changed: true then changed: false against the same
parameters is exactly what idempotency looks like.
Loop 2: ad-hoc through ansible-core
The next loop up adds real argument validation, real check mode and the real execution path, still without packaging anything.
export ANSIBLE_LIBRARY=./library
# Check mode: real validation, real check-mode branch, no change.
ansible -m app_setting -a 'path=/tmp/app.conf key=max_workers value=16 api_token=REPLACE_ME' localhost --check
# The full path, including AnsiballZ packing and the remote interpreter.
ansible -m app_setting -a 'path=/tmp/app.conf key=max_workers value=16' localhost -vvv$ ansible -m app_setting -a 'path=/tmp/app.conf key=max_workers value=8' localhost --checklocalhost | SUCCESS => {
"changed": false,
"previous_value": "8"
}
localhost | CHANGED => {
"changed": true,
"previous_value": "8"
}Contract item 3 in two commands: the module reports what it would do
and does nothing. That second result — CHANGED under --check with the
file untouched — is what real check-mode support looks like.
Loop 3: ansible-test sanity
The sanity suite is the only automated check of several contract items,
and it ships with ansible-core. It has one structural requirement that
stops people on first use.
$ ansible-test sanity --test validate-modules plugins/modules/app_setting.pyFATAL: The current working directory must be within the source tree being tested.
Testing an Ansible collection: {...}/ansible_collections/{namespace}/{collection}/
Example #1: community.general -> ~/code/ansible_collections/community/general/
No "ansible_collections" parent directory was found.With the tree arranged correctly, the suite reports real defects:
$ ansible-test sanity --test validate-modules plugins/modules/app_setting.pyERROR: Found 3 validate-modules issue(s) which need to be resolved:
ERROR: plugins/modules/app_setting.py:0:0: missing-examples: No EXAMPLES provided
ERROR: plugins/modules/app_setting.py:0:0: missing-gplv3-license: GPLv3 license header not found in the first 20 lines of the module
ERROR: plugins/modules/app_setting.py:0:0: no-log-needed: Argument 'key' in argument_spec could be a secret, though doesn't have `no_log` set
FATAL: The 1 sanity test(s) listed below (out of 1) failed. See error output above for details.
validate-modulesRead those three, because each teaches something different.
missing-examples is a documentation gap that costs nothing at run
time and everything to the colleague reading ansible-doc at 03:00.
missing-gplv3-license is a licensing requirement for collection
modules. It is mechanical, and it matters if the collection ever leaves
your organisation.
no-log-needed on key is the interesting one, and it is a false
positive. The check flags any argument whose name pattern suggests a
credential — key does, even though here it holds a setting name. The
resolution is to declare the intent explicitly:
argument_spec=dict(
path=dict(type='path', required=True),
key=dict(type='str', required=True, no_log=False),
value=dict(type='str', required=True),
api_token=dict(type='str', required=False, no_log=True),
),With EXAMPLES added, the licence header present and no_log=False
declared, the same command passes:
$ ansible-test sanity --test validate-modules plugins/modules/app_setting.py; echo exit=$?Running sanity test "validate-modules"
exit=0The full ansible-test sanity run adds pep8, pylint, yamllint,
import checks and several more. Start with validate-modules because it
is the one that maps onto lesson 3’s contract, then add the rest.
Shipping it: a collection, not a copy
library/ is right for development and wrong for distribution, and the
failure is entirely predictable.
The module is useful, so a second repository needs it, so somebody
copies library/app_setting.py. Then a third. A bug is found and fixed
in one. Eighteen months later there are three files with the same name,
different behaviour, and no way to tell which one a given play ran.
A collection gives the module a namespace, a version and a single source.
mkdir -p ~/code/ansible_collections/example
cd ~/code/ansible_collections/example
ansible-galaxy collection init example.fleet --init-path .
mkdir -p fleet/plugins/modules
cp /srv/ansible/library/app_setting.py fleet/plugins/modules/namespace: example
name: fleet
version: 1.0.0
readme: README.md
authors:
- Example Team <ops@example.com>
description: Internal modules for the example.com fleet
license:
- GPL-3.0-or-later
repository: https://git.example.com/ansible/collection-fleet
tags:
- internal$ ansible-galaxy collection build --output-path ./distCreated collection for example.fleet at /home/ansible/code/dist/example-fleet-1.0.0.tar.gzThe module is now example.fleet.app_setting in a play, and pinnable in
requirements.yml:
---
collections:
- name: example.fleet
version: 1.0.0
source: https://galaxy.example.com/api/galaxy/content/internal/Knowledge check
Knowledge check · 4 questions
Q1. What does running a module directly as `python library/my_module.py args.json` exercise, and what does it skip?
Q2. Which of these does packaging a module into a versioned collection give you that a copied library/ file does not? Select all that apply.
Q3. When ansible-test sanity reports `no-log-needed` on an argument, the correct fix is always to add no_log=True.
Q4. Why does ansible-test refuse to run unless the collection sits under a directory named ansible_collections?
Passing score: 75%. Answers are checked in this browser.