Skip to main content
RunBook Academy

AnsibleLI · Custom Modules and Tool SelectionWriting and shipping a module

The contract a module owes its users

Expert⏱ ~30 minbashpython3

What you'll learn

  • Review a custom module against six contract requirements
  • Name the operational failure each requirement prevents
  • Distinguish a module that is idempotent from one that merely reports as if it were
  • Apply the contract as a pull-request checklist rather than as advice

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 is a promise made to every play that uses it, and to every engineer who reads a recap it produced. Because ansible-core verifies none of it, the promise is only as good as the review that let the code in.

This lesson is that review, as six requirements. Each is stated as a property the module must have, and each is paired with the specific operational failure that results when it does not — because “be idempotent” is advice, and “a non-idempotent module makes a rerun after a partial failure unsafe, which removes your only recovery move” is a reason.

1. Genuine idempotency

The property. Running the module twice with the same parameters against the same target leaves the target in the same state as running it once, and the second run reports no change.

The failure it prevents. A run stops halfway through a 300-host fleet. Your only recovery move is to fix the cause and rerun — that is what Part XII established, and it is why idempotency is the foundation of everything in this course. A module that appends rather than sets, or that creates rather than ensures, makes the rerun a second change: two entries in a config file, two users, a counter incremented twice.

How to be wrong. The commonest form is a module that reports idempotently while behaving otherwise. It writes the file every time and compares afterwards to decide what to report. The recap is honest-looking and the file’s mtime changes on every run, which breaks anything watching it, and any handler keyed on a genuine change is now firing on a converged fleet.

2. An accurate changed value

The property. changed: true means this run modified the target. changed: false means it did not. Both directions matter.

The failure it prevents. Two, and they are different incidents.

Always changed means every handler subscribed to the task fires on every run. On a converged fleet of 300 hosts that is a nightly restart storm — the break/fix scenario for Part LII — and the diagnosis is hard because the play is not obviously wrong.

Never changed is quieter and worse. Handlers that should fire do not, so a config file is written and the service is never reloaded. The host runs the old configuration while the repository, the recap and the drift report all agree that it is converged.

How to be wrong. Reporting intent rather than outcome, which is what fail_json(**result) with an optimistically-set changed does, as lesson 2 demonstrated. Also: changed: true on a read-only operation because it seemed harmless, and returning changed from a helper function that does not know whether the write succeeded.

3. Real check mode

The property. Under --check the module makes no modification, and reports the changed value it would have produced.

The failure it prevents. A dry run that modifies production. Somebody runs --check against 40 hosts to preview a change — the sanctioned, encouraged, careful thing to do — and the module changes them. Everything about that run said it was safe.

How to be wrong. Declaring supports_check_mode=True and not branching on module.check_mode. This is not obscure; it happens because the flag is added early, when the module is a stub, and the write is added later by somebody who did not know the flag was a promise.

A second, subtler form: branching on check mode after the write. The structural rule is that the check-mode exit precedes the first state-changing statement, and that there is exactly one such statement, so the rule is checkable by reading.

4. no_log=True on every secret argument

The property. Any parameter carrying a credential is declared no_log=True in the argument_spec.

The failure it prevents. The token is written into the invocation record on every run, at normal verbosity in the result and at -vvv in full — which means into whatever your callback plugin ships, into CI job logs that are retained for a year, and into the terminal scrollback of everyone who has run the play.

How to be wrong. Missing one. It is usually not the argument named password; it is api_token, secret_key, bind_password, or a url that contains credentials inline. Grep your argument_spec for anything whose value would be sensitive rather than anything whose name looks sensitive.

Also: returning the secret. no_log on the argument does not protect a return value you constructed yourself. Do not put the token in the result, and do not put it in a msg.

5. Failure messages that name the cause

The property. fail_json(msg=...) says what failed, on what, and why — in a sentence an engineer can act on without opening the source.

The failure it prevents. Twenty minutes of debugging per incident, multiplied by every incident, at the hour when the module’s author is asleep.

Read-only / Safethe difference, in two lines of Python
# Unhelpful: names the symptom, not the cause.
module.fail_json(msg='operation failed')

# Unhelpful in a different way: leaks the internals and the credential.
module.fail_json(msg='POST %s failed: %s' % (url_with_token, exc))

# Useful: what, where, why, and what the caller can do.
module.fail_json(
  msg='cannot write %s: %s. The parent directory must exist and be '
      'writable by the user the play runs as.' % (path, exc),
  changed=False,
)

How to be wrong. Passing the exception object alone; including a URL that contains a token; using a message that describes the code path ("state machine in unexpected state") rather than the operational condition.

6. Documented return values

The property. The RETURN block describes every key the module returns, when it is returned, and its type — and ansible-doc renders it.

The failure it prevents. A play registers the module’s result and reaches into it. Six months later the module’s author changes a key name; nothing warns, and the play’s when: silently evaluates against an undefined value. Documented returns are the interface contract that makes such a change a visible breaking change rather than an accident.

How to be wrong. Omitting RETURN entirely, or documenting the happy path only. If a key appears solely on failure, say returned: on failure.

The checklist

Reviewable form. A module that cannot answer all eight is not ready for a fleet.

  1. Run it twice against the same target. Does the second run report no change, and is the target byte-identical - not merely reported as unchanged?
  2. Does every path that sets changed: true correspond to a modification that actually succeeded? Check the fail_json paths specifically.
  3. Is there exactly one statement that modifies the target, and does the check_mode exit precede it?
  4. Under --check, does the module still perform the comparison and report the changed value it would have produced?
  5. Is every parameter whose value would be sensitive declared no_log=True, and is no secret present in any return value or msg?
  6. Does every fail_json message name what failed, on what, and why - without leaking a credential?
  7. Does RETURN document every key, with returned: and type:, and does ansible-doc render the module without error?
  8. Does DOCUMENTATION list any non-standard-library requirement the managed node must have?

Knowledge check

Knowledge check · 5 questions

  1. Q1. A module rewrites the target file on every run, then compares the new content against what it read at the start to decide what to report. The recap looks correct. What is wrong?

  2. Q2. Which failures does an inaccurate `changed` value produce? Select all that apply.

  3. Q3. A module that returns changed: false under --check because it took no action satisfies the letter of check-mode support while defeating its purpose.

  4. Q4. Why does ansible-core not verify a module's idempotency or its changed value automatically?

  5. Q5. A module performs an operation that genuinely cannot be idempotent, such as sending a notification. What is the correct treatment?

Passing score: 75%. Answers are checked in this browser.