Skip to main content
RunBook Academy

AnsibleLI · Custom Modules and Tool SelectionWriting and shipping a module

Testing it, then shipping it properly

Expert⏱ ~32 minbashpython3

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

Not yet marked complete on this device.

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.

Read-only / Safeargs.json - the exact structure AnsibleModule expects
{
"ANSIBLE_MODULE_ARGS": {
  "path": "/tmp/app.conf",
  "key": "max_workers",
  "value": "8",
  "api_token": "REPLACE_ME"
}
}
Configuration changetwo runs, proving idempotency in under a second
$ 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.

Configuration changerun an unpackaged module ad-hoc
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
Read-only / Safecheck mode against a converged target, then a divergent one
$ ansible -m app_setting -a 'path=/tmp/app.conf key=max_workers value=8' localhost --check
localhost | 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.

Read-only / Saferunning ansible-test outside a collection tree
$ ansible-test sanity --test validate-modules plugins/modules/app_setting.py
FATAL: 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:

Read-only / Safevalidate-modules on the module from lesson 2
$ ansible-test sanity --test validate-modules plugins/modules/app_setting.py
ERROR: 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-modules

Read 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:

Read-only / Safeanswering no-log-needed honestly
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:

Read-only / Safethe same module after the three fixes
$ ansible-test sanity --test validate-modules plugins/modules/app_setting.py; echo exit=$?
Running sanity test "validate-modules"
exit=0

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

Configuration changecreate the collection and place the module in it
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/
Read-only / Safegalaxy.yml - the metadata that makes it a versioned artefact
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
Configuration changebuild the distributable artefact
$ ansible-galaxy collection build --output-path ./dist
Created collection for example.fleet at /home/ansible/code/dist/example-fleet-1.0.0.tar.gz

The module is now example.fleet.app_setting in a play, and pinnable in requirements.yml:

Read-only / Safeconsuming the collection from a project
---
collections:
- name: example.fleet
  version: 1.0.0
  source: https://galaxy.example.com/api/galaxy/content/internal/

Knowledge check

Knowledge check · 4 questions

  1. Q1. What does running a module directly as `python library/my_module.py args.json` exercise, and what does it skip?

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

  3. Q3. When ansible-test sanity reports `no-log-needed` on an argument, the correct fix is always to add no_log=True.

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