Chapter 13: Extending Ansible & Automation at Scale
Topic 75

Writing Custom Modules

ExtendingModules

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 custom-module contract — arguments in, a JSON result out
AnsibleModule(argument_spec, supports_check_mode)
do the work, check state
exit_json(changed=…) as JSON

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.

library/app_release.py — a custom module built on AnsibleModule
#!/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.

Common Mistakes
  • Returning changed=True unconditionally because you never compared against current state — every run reports a change, handlers fire needlessly, and --check becomes meaningless since the module always claims it would act.
  • Ignoring module.check_mode and making real changes during a --check run, 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, required and choices validation, and the no_log masking that keeps a password argument out of the logs and the verbose output.
  • Writing the "module" as a shell or command call 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.
Best Practices
  • Build every module on AnsibleModule with a complete argument_spec, so type validation, choices, and no_log masking come for free instead of being hand-rolled and forgotten.
  • Make the module read-then-decide: gather current state, compute the diff, and set changed only on a real change, so idempotency and --check both hold by construction.
  • Implement check_mode from 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=True on every secret-bearing argument, so tokens and passwords stay out of the task log and the -vvv output 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.
Comparable tools Puppet custom types & providers · Chef custom resources the agent-world equivalents Terraform custom provider the provisioning-layer analog, heavier — Go and a real plugin protocol A raw shell task the fragile thing a custom module replaces

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 --check becomes 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=True result 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, required and choices checking, and no_log masking, 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 the changed it 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 --check only ever affects the built-in core modules
  • Return failed=True to 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