Writing Custom Ansible Modules in Python With AI Help
Use AI to draft a custom Ansible Python module with proper check_mode, argument_spec, no_log secrets and real idempotency, then have a human review every line.
- #iac
- #ansible
- #ai
- #python
- #modules
I have a confession. For about two years, a critical piece of our provisioning ran on a single Ansible task that shelled out to curl against an internal billing API, piped the response through jq, and registered the result. It worked, mostly. It also lied about changed every single run, had no idea what --check meant, and leaked a bearer token into the logs the one time someone forgot to set no_log: true. I reached for command: one too many times, and after the third 2 a.m. page caused by that brittle pipeline, I finally did the thing I’d been avoiding: I wrote a real module.
The good news is that custom Ansible modules are far less scary than they look. They’re just Python programs that read JSON on stdin and write JSON on stdout, with a helper class that handles the boring parts. The better news is that an LLM can draft the boilerplate in seconds, which removes the single biggest excuse for not writing one. The catch — and I’ll keep hammering this — is that AI is a fast junior engineer. It writes plausible modules. Plausible is not the same as correct, and with infrastructure code the gap between the two is measured in outages.
Where the file actually goes
Before any code, the boring-but-mandatory bit: Ansible discovers custom modules in a library/ directory next to your playbook, or in a path listed under library in ansible.cfg. Drop your module there and it becomes a task name automatically.
myproject/
├── ansible.cfg
├── site.yml
└── library/
└── billing_account.py
The module name is the filename minus .py. That’s the whole “registration” story. No plugin manifest, no entry point. If you’re sharing across many playbooks, a proper collection is the grown-up answer, but for a single internal module, library/ is correct and I won’t apologize for it.
The AnsibleModule skeleton
Here’s the minimum viable module. I asked an LLM for a first draft, then rewrote about half of it — more on that below. This is the cleaned-up version I’d actually commit:
#!/usr/bin/python
from __future__ import annotations
from ansible.module_utils.basic import AnsibleModule
def run_module():
module_args = dict(
name=dict(type="str", required=True),
state=dict(type="str", default="present", choices=["present", "absent"]),
plan=dict(type="str", required=False, default="standard"),
api_token=dict(type="str", required=True, no_log=True),
)
result = dict(changed=False, account={})
module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True,
)
name = module.params["name"]
desired_state = module.params["state"]
current = lookup_account(module) # returns dict or None
if desired_state == "present":
if current and current["plan"] == module.params["plan"]:
result["account"] = current
module.exit_json(**result)
result["changed"] = True
if module.check_mode:
module.exit_json(**result)
result["account"] = upsert_account(module)
else:
if not current:
module.exit_json(**result)
result["changed"] = True
if module.check_mode:
module.exit_json(**result)
delete_account(module)
module.exit_json(**result)
def main():
run_module()
if __name__ == "__main__":
main()
A few things are load-bearing here. argument_spec is the contract: every parameter gets a type, and required ones get required=True. Ansible validates this for you and fails cleanly if a playbook passes garbage — you do not write your own validation loop. The result dict carries changed, which is the single most important value your module returns, plus whatever data you want surfaced back to the play.
no_log is not optional
Look at api_token again:
api_token=dict(type="str", required=True, no_log=True),
That no_log=True is the difference between a clean run and a secret sitting in plaintext in your CI logs, your -vvv output, and anyone’s terminal scrollback. Ansible scrubs any parameter marked no_log from all output, including the task result and verbose dumps. Every argument that could carry a credential — tokens, passwords, private keys, connection strings — gets no_log=True. No exceptions, no “it’s just a dev token.”
Pro Tip: when an LLM drafts your argument_spec, the first thing to audit is whether it put no_log=True on the secret-bearing args. In my experience it gets this right maybe half the time. It loves to declare password=dict(type="str", required=True) and walk away whistling. Treat a missing no_log as a security bug, not a style nit.
Honoring check_mode for real
Declaring supports_check_mode=True only tells Ansible you promise to behave under --check. It does not enforce anything. The promise lives in your own branches:
result["changed"] = True
if module.check_mode:
module.exit_json(**result) # report intent, change nothing
result["account"] = upsert_account(module) # only runs for real
The pattern is always the same: compute whether a change would happen, set changed accordingly, and if module.check_mode is true, exit before the side effect. This is exactly the kind of code an AI loves to fake. I’ve seen drafts that set supports_check_mode=True, then call the write function unconditionally — a module that cheerfully mutates production during a dry run. That’s worse than no check_mode support at all, because now you trust a --check that lies.
Pro Tip: never accept an AI’s word that check_mode works. Point the module at a real-ish target, run the play with --check, and confirm with your own eyes that nothing changed. Then run it again live and confirm changed flips correctly. If the dry run touches state, the module is broken regardless of what the diff looks like.
Idempotency is the whole job
A module that reports changed: true every run is a broken module, full stop. Idempotency means: read current state, compare to desired state, and only act — and only report changed — when they differ. That lookup_account call exists entirely so we can skip the write when reality already matches intent:
if current and current["plan"] == module.params["plan"]:
result["account"] = current
module.exit_json(**result) # already correct, changed stays False
This is the part AI consistently under-delivers. It will happily generate the “do the thing” path and forget the “is the thing already done?” check, because idempotency requires understanding your actual API, not just Python syntax. The comparison logic is where you, the human, earn your keep.
Failing loudly with fail_json
When something goes wrong, do not raise a bare exception and dump a traceback into someone’s play. Call module.fail_json() with a useful message:
import json
from ansible.module_utils.urls import open_url
def lookup_account(module):
try:
resp = open_url(
f"https://billing.internal/api/accounts/{module.params['name']}",
headers={"Authorization": f"Bearer {module.params['api_token']}"},
validate_certs=True,
)
except Exception as exc:
module.fail_json(msg=f"Failed to query billing API: {exc}")
return json.loads(resp.read())
fail_json ends the module cleanly, marks the task failed, and respects no_log on the way out. Note validate_certs=True — an LLM will reach for validate_certs=False to make its example “just work,” and that’s another review catch.
Documentation strings
Ansible expects three module-level strings: DOCUMENTATION, EXAMPLES, and RETURN. These power ansible-doc and aren’t decoration — they’re how the next person uses your module without reading the source. This is, frankly, the one place I’m happy to let AI do most of the typing, because it’s structured YAML-in-a-string and low-risk:
DOCUMENTATION = r"""
---
module: billing_account
short_description: Manage internal billing accounts
options:
name:
description: "Unique account name."
required: true
type: str
state:
description: "Whether the account should exist."
default: present
choices: ["present", "absent"]
type: str
api_token:
description: "Bearer token for the billing API."
required: true
type: str
"""
EXAMPLES = r"""
- name: "Ensure account exists"
billing_account:
name: "acme-corp"
plan: "enterprise"
api_token: "{{ billing_token }}"
"""
RETURN = r"""
account:
description: "The account object after the operation."
type: dict
returned: always
"""
Using it from a playbook
Finally, the payoff — a clean, readable task instead of a command: plus a regex prayer:
---
- name: "Provision billing accounts"
hosts: localhost
gather_facts: false
vars:
billing_token: "{{ lookup('env', 'BILLING_TOKEN') }}"
tasks:
- name: "Ensure ACME account is on the enterprise plan"
billing_account:
name: "acme-corp"
state: "present"
plan: "enterprise"
api_token: "{{ billing_token }}"
register: acme
- name: "Show what changed"
ansible.builtin.debug:
msg: "Account changed: {{ acme.changed }}"
Note the YAML hygiene: colon-bearing strings are quoted, there are no tabs, and the secret comes from an environment lookup rather than a literal in the file. The token never appears in the playbook, and no_log=True keeps it out of the run output even when something fails.
How I actually let AI help
The workflow that works for me: I describe the module’s job and the API shape to the model, let it produce the skeleton, argument_spec, and doc strings, then I rewrite the state-comparison and check_mode logic by hand. AI gives me a running start on the 60% that’s boilerplate; I own the 40% that’s correctness. If you want a sharper draft, feed the model a tighter brief from a reusable prompt library or one of the IaC-focused prompt packs, and run the conversation in a tool you trust like Claude. When the draft comes back, I run it through our automated code review before a human ever sees it, which catches the missing no_log and the validate_certs=False reflexes nine times out of ten.
What I never do is hand the model real secrets or vault keys. The AI gets the shape of the credential argument — its name and type — never an actual token. Drafting boilerplate does not require live secrets, and pasting one into a chat window is how they end up somewhere you can’t recall them from.
Wrapping up
Custom Ansible modules turn fragile shell pipelines into idempotent, check-mode-aware, secret-safe building blocks, and AI removes the activation energy that kept you writing command: for years. Let it draft the skeleton. Then do the unglamorous human work: verify every secret arg has no_log=True, prove check_mode is honored by running --check and watching for zero changes, and confirm changed only flips when state actually moves. The model is a tireless junior engineer. You’re still the one who signs off. For more in this vein, browse the rest of the IaC category.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.