Writing Custom Modules
When no existing module does what you need, you write one — a small program Ansible copies to the managed node and runs, that receives JSON arguments from Ansible and prints a JSON result to stdout. That is the whole module contract: arguments in, a result out, with at least a changed flag saying whether anything was altered. Everything between is your code.
A custom module is the right answer the moment you would otherwise reach for command or shell against an API or a tool that has no module — because the module is where idempotency and check-mode support live. Wrapping a curl call in shell gets the job done once and loses both. Writing a real module is how you keep "no module exists for our internal release service" from quietly becoming "we gave up on idempotency for our deploys."
The Module Contract
A module receives its arguments as a JSON object and must print a JSON object back, containing at minimum changed set to true or false plus whatever results the task should expose as registered facts. There are exactly two ways out: exit_json for success and fail_json for failure. Success with changed=false means "already in the desired state, did nothing"; success with changed=true means "it wasn't, and now it is." Everything else — reading current state, deciding what to do, doing it — is logic you write.
That contract is deliberately small because Ansible handles the transport. It serializes the task's parameters, ships your program to the node, runs it under the task's privileges, captures the stdout, and parses the JSON back into the result the play sees. You never write the SSH, the copying, or the wire format — you write a program that reads structured input and emits a structured answer.
The AnsibleModule Helper
You do not parse that JSON by hand. from ansible.module_utils.basic import AnsibleModule gives you argument parsing, type validation, check-mode detection, and the exit_json and fail_json helpers in one object. You declare your inputs with argument_spec — a dictionary where each argument names its type, whether it is required, a default, and an optional choices list — and Ansible validates every parameter against that spec before a line of your code runs. A wrong type or a missing required argument fails the task cleanly, with a useful message, before you ever touch the API.
#!/usr/bin/python from ansible.module_utils.basic import AnsibleModule import release_api # your internal client def main(): module = AnsibleModule( argument_spec=dict( app=dict(type="str", required=True), version=dict(type="str", required=True), state=dict(type="str", default="present", choices=["present", "absent"]), token=dict(type="str", required=True, no_log=True), ), supports_check_mode=True, ) app = module.params["app"] want = module.params["version"] current = release_api.deployed_version(app, module.params["token"]) # read current state, compare, decide if current == want: module.exit_json(changed=False, version=current) if module.check_mode: module.exit_json(changed=True, version=want, msg="would deploy") release_api.deploy(app, want, module.params["token"]) module.exit_json(changed=True, version=want) if __name__ == "__main__": main()
Note no_log=True on the token argument: that one flag keeps the secret out of the task logs and the -vvv output, which hand-rolled parsing would never give you for free. The spec is the difference between a module that validates and masks its inputs and one that trusts whatever the playbook throws at it.
Idempotency Is Yours to Enforce
Ansible does not make your module idempotent — you do. The module must read the current state, compare it to the requested state, and report changed=True only when it actually changes something. Nothing in the framework checks this for you. A module that always returns changed=True is exactly as dishonest as a shell task that re-runs blindly: handlers fire on every run, the change count lies, and the all-green-on-the-second-pass signal you rely on everywhere else is gone. In the example above, the current == want check is the entire discipline — read first, then decide.
check_mode Support
module.check_mode is True when the play runs with --check. A correct module reads that flag, computes what it would do, reports the changed it would have produced, and makes no actual change. You declare the capability with supports_check_mode=True in the constructor and then branch on the flag before any mutation — the example does exactly that, returning changed=True with a "would deploy" message instead of calling deploy(). Skip this and the dry run your team trusts before a prod deploy silently lies, because your module changed the world during what was supposed to be a no-op.
Where It Lives
A single module drops into a library/ directory next to your playbook and Ansible finds it automatically — no configuration, no install. That is fine for something specific to one repo. But the moment a second project needs it, that module belongs in a collection under plugins/modules/, addressed by a fully qualified name like larkspur.web.app_release, versioned and shared like any other collection content. The line is simple: library/ for the one-off local to a playbook, a collection for anything reused, so there is one source of truth instead of copies drifting across repos.
- Returning
changed=Trueunconditionally because you never compared against current state — every run reports a change, handlers fire needlessly, and--checkbecomes meaningless since the module always claims it would act. - Ignoring
module.check_modeand making real changes during a--checkrun, so the dry run the team trusts before a prod deploy is silently doing the deploy it claims only to preview. - Building the argument parsing by hand instead of using
argument_spec, losing type coercion,requiredandchoicesvalidation, and theno_logmasking that keeps a password argument out of the logs and the verbose output. - Writing the "module" as a
shellorcommandcall wrapped in module clothing instead of real Python against the API, so you reinvent the exact imperative fragility a module exists to replace. - Leaving a reusable module permanently in one repo's
library/, so three other projects copy-paste it and the four copies drift — it belonged in a collection with an FQCN from the second use onward.
- Build every module on
AnsibleModulewith a completeargument_spec, so type validation,choices, andno_logmasking come for free instead of being hand-rolled and forgotten. - Make the module read-then-decide: gather current state, compute the diff, and set
changedonly on a real change, so idempotency and--checkboth hold by construction. - Implement
check_modefrom the first commit by branching before any mutation, not as a fix bolted on once someone files a bug about a dry run that wasn't dry. - Set
no_log=Trueon every secret-bearing argument, so tokens and passwords stay out of the task log and the-vvvoutput even when someone runs with full verbosity. - Promote a module out of
library/into a versioned collection the moment a second project needs it, so there is one FQCN-addressed source of truth instead of copies.
Knowledge Check
A custom module always returns changed=True. Why is that a bug, not a harmless default?
- It never compares against current state, so handlers fire every run and
--checkbecomes meaningless because the module always claims it would act - Ansible flatly refuses to run any custom module that reports a change unless a structured diff payload is attached to the returned result dictionary
- A
changed=Trueresult forces the play to abort immediately after that first task completes - It roughly doubles the play runtime because Ansible automatically re-executes any task whose module reports a change
What does declaring inputs with AnsibleModule(argument_spec=...) give you that hand-parsing the JSON does not?
- Type validation,
requiredandchoiceschecking, andno_logmasking, all applied before your code runs - Automatic idempotency for free, so your module no longer needs to compare its inputs against the current system state
- A firm guarantee that the module runs on the control node itself instead of being shipped to the managed host
- Built-in retry logic that automatically reruns the module on the host until it finally reports no change
A play runs with --check. What must a correct custom module do?
- Detect
module.check_mode, compute and report thechangedit would produce, and make no actual change - Skip itself entirely and return early, since check mode is documented to never invoke custom modules at all
- Apply the real change to the host anyway, because
--checkonly ever affects the built-in core modules - Return
failed=Trueto signal that it does not support dry runs
When should a module move out of a playbook's library/ directory into a collection?
- As soon as a second project needs it, so there is one FQCN-addressed source of truth instead of copies that drift
- Only once the module's source grows past a few hundred lines of Python and becomes too unwieldy to keep sitting in
library/ - Never — modules in
library/are faster because Ansible skips collection resolution - When you specifically want the module to run on the control node rather than be shipped to the managed host
You got correct