Module 10 — Custom Modules & Plugins
Updated 20 August 2026
Extending Ansible when nothing existing fits — and, just as importantly, recognising the far more common case where something already does.
🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)
Prerequisite: Modules 01–09. You need the execution model from Module 01 Part B6 and the collection layout from Module 06.
Part A · When to write one, and when not to
A1 · The decision tree
First you check the drawer, because you probably already have one. Then you consider whether an ordinary screwdriver would do the job. Then whether you could borrow one for the afternoon.
Forging your own is the last option, not the first — and once you have made it, you own it forever: sharpening it, storing it, replacing it when it breaks.
Writing a custom module is forging the tool. Sometimes exactly right, and the reason the honest interview answer is "rarely, and deliberately".
Diagram source
flowchart TD
A["I need something<br>Ansible does not do"] --> B{"Search the module index.<br>Does one exist?"}
B -->|"Yes"| B1["Use it. Stop here."]
B -->|"No"| C{"Is it an HTTP API?"}
C -->|"Yes"| C1["Use the uri module<br>wrapped in a role"]
C -->|"No"| D{"Is it data transformation<br>rather than an action?"}
D -->|"Yes"| D1["Write a FILTER plugin<br>far simpler than a module"]
D -->|"No"| E{"Is it fetching data<br>on the control node?"}
E -->|"Yes"| E1["Write a LOOKUP plugin"]
E -->|"No"| F{"Used more than<br>a few times?"}
F -->|"No"| F1["A guarded command task<br>is honest and cheaper"]
F -->|"Yes"| G["Write a MODULE"]
style B1 fill:#D1FAE5,stroke:#059669,stroke-width:2px
style G fill:#DDD6FE,stroke:#7C3AED,stroke-width:2pxThat framing shows judgement. Enthusiasm for writing modules reads as inexperience.
A2 · What a module buys you over command
The first says "I'll start Monday" and begins knocking walls down. You find out what he did when you get home.
The second walks the house first and says: "That wall is already the right size, so I'll leave it. This one needs moving. Here's exactly what I'd change — shall I proceed?"
The second builder is a real module. Checking before acting is idempotency; describing the work without doing it is check mode. Those two behaviours are the entire reason a module is worth writing instead of using command.
| command / shell | A real module | |
|---|---|---|
| Idempotency | ❌ You bolt it on with creates or changed_when | ✅ Built in — it inspects state first |
| Check mode | ❌ Skips entirely — your dry run is worthless | ✅ Reports what would change |
| Argument validation | ❌ None | ✅ Types, choices, required, mutually exclusive |
| Structured return | ❌ You parse stdout | ✅ JSON with documented keys |
| Secret handling | ❌ Arguments appear in logs | ✅ no_log=True censors automatically |
| Documentation | ❌ A comment, if you are lucky | ✅ ansible-doc renders it |
🎯 Interview questions — When to extend Ansible
Q. When would you write a custom module?
When there is no existing module for the system, the operation needs to be idempotent, and it needs to support check mode — those two properties are what a command task fundamentally cannot provide.
Before that: search the module index, consider the uri module for anything HTTP, consider a filter plugin if it is data transformation rather than an action, and consider whether a guarded command task used twice is honestly cheaper than a module you must then test and maintain.
Q. What does a module give you that command does not?
Built-in idempotency — it inspects current state and acts only if needed. Check-mode support, where command skips entirely and makes your dry run worthless. Argument validation with types, choices and required fields. A structured JSON return instead of parsed stdout. Automatic secret censoring via no_log=True in the argument spec. And documentation ansible-doc can render.
Part B · Anatomy of a module
B1 · The complete skeleton
Look, then describe, then act — and that order is the shape of every module skeleton below. It is not a stylistic preference: a builder who swings first and asks afterwards has knocked your wall down during what you thought was a quote, which in code means a module that ignores check_mode and changes production during a --check run.
#!/usr/bin/python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: tenant
short_description: Manage platform tenants
version_added: "1.0.0"
description:
- Creates, updates and removes tenants in the internal platform API.
options:
name:
description: Tenant name.
required: true
type: str
quota_gb:
description: Storage quota in gigabytes.
required: false
type: int
default: 10
state:
description: Desired state.
type: str
choices: [present, absent]
default: present
api_token:
description: API token for the platform.
required: true
type: str
author:
- Zaeem Mazhar (@zaeem)
'''
EXAMPLES = r'''
- name: Ensure a tenant exists
mycompany.platform.tenant:
name: acme
quota_gb: 50
api_token: "{{ vault_platform_token }}"
- name: Remove a tenant
mycompany.platform.tenant:
name: acme
state: absent
api_token: "{{ vault_platform_token }}"
'''
RETURN = r'''
tenant:
description: The tenant object after the change.
returned: when state is present
type: dict
sample: {"name": "acme", "quota_gb": 50, "id": "t-123"}
changed:
description: Whether anything was modified.
returned: always
type: bool
'''
from ansible.module_utils.basic import AnsibleModule
def get_tenant(module, name):
"""Return the tenant dict, or None if it does not exist."""
# real implementation would call the API here
return None
def run_module():
argument_spec = dict(
name=dict(type='str', required=True),
quota_gb=dict(type='int', default=10),
state=dict(type='str', default='present', choices=['present', 'absent']),
api_token=dict(type='str', required=True, no_log=True),
)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_if=[('state', 'present', ['quota_gb'])],
)
name = module.params['name']
state = module.params['state']
result = dict(changed=False)
current = get_tenant(module, name)
# 1 - already in the desired state: do nothing, report ok
if state == 'present' and current and current['quota_gb'] == module.params['quota_gb']:
result['tenant'] = current
module.exit_json(**result)
if state == 'absent' and not current:
module.exit_json(**result)
# 2 - a change IS needed. In check mode, say so and stop.
if module.check_mode:
result['changed'] = True
module.exit_json(**result)
# 3 - actually make the change
try:
if state == 'present':
result['tenant'] = {'name': name, 'quota_gb': module.params['quota_gb']}
result['changed'] = True
except Exception as e:
module.fail_json(msg="Failed to manage tenant %s: %s" % (name, str(e)), **result)
module.exit_json(**result)
def main():
run_module()
if __name__ == '__main__':
main()1. Check current state first. If it already matches, exit_json with changed=False. This is idempotency, and it must come before anything else.
2. Handle check_mode before acting. If a change is needed and module.check_mode is true, report changed=True and exit without doing anything.
3. Only then make the change.
Get that order wrong — acting before the check-mode guard — and your module modifies production during a dry run.
B2 · The argument spec
The argument_spec is the clerk — types, required fields, allowed choices and defaults, all checked before your module runs a single line. And no_log=True is the clerk not reading your account number aloud across the counter, which is the same distinction Module 07 drew between the safe and the cashier's voice.
| Key | Purpose |
|---|---|
| type | str, int, bool, list, dict, path, raw, json. Ansible casts for you |
| required | Fail before running if absent |
| default | Applied when omitted |
| choices | Enumerated valid values — fails with a clear message otherwise |
| no_log=True | ⭐ Censors this value everywhere — output, -vvv, logs. Mandatory on any secret |
| elements | Type of items inside a list |
| aliases | Alternative parameter names |
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_if=[('state', 'present', ['quota_gb'])], # conditional requirement
required_together=[['username', 'password']], # all or none
required_one_of=[['name', 'id']], # at least one
mutually_exclusive=[['name', 'id']], # at most one
)This is a genuine advantage of a module over command, and it is worth naming as one.
B3 · Returning results
module.exit_json(changed=True, tenant={'name': 'acme'}, msg="Created")
module.fail_json(msg="Could not reach the API", status_code=503)
# Useful helpers on the module object
rc, stdout, stderr = module.run_command(['/usr/bin/thing', '--flag'], check_rc=True)
module.warn("quota_gb below 5 is not recommended")
module.deprecate("The 'size' option is deprecated", version="2.0.0")
module.check_mode # bool
module.params # the validated, type-cast parametersUse module.warn() for messages, and module.fail_json() rather than raising an uncaught exception, so the failure is structured and readable.
🧪 Exercise B3.1 — Run a module by hand, with no Ansible at all
mkdir -p library
# save the skeleton above as library/tenant.py
# A module reads JSON args from a file passed as argv[1]
cat > /tmp/args.json <<'EOF'
{"ANSIBLE_MODULE_ARGS": {"name": "acme", "quota_gb": 50, "api_token": "secret123", "state": "present"}}
EOF
python3 library/tenant.py /tmp/args.json
# Now omit a required argument
echo '{"ANSIBLE_MODULE_ARGS": {"quota_gb": 50}}' > /tmp/bad.json
python3 library/tenant.py /tmp/bad.json
# And an invalid choice
echo '{"ANSIBLE_MODULE_ARGS": {"name": "x", "api_token": "t", "state": "maybe"}}' > /tmp/bad2.json
python3 library/tenant.py /tmp/bad2.json✅ Expected result — click to reveal
{"changed": true, "tenant": {"name": "acme", "quota_gb": 50}, "invocation": {"module_args": {"name": "acme", "quota_gb": 50, "state": "present", "api_token": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER"}}}{"failed": true, "msg": "missing required arguments: api_token, name"}{"failed": true, "msg": "value of state must be one of: present, absent, got: maybe"}Three things worth noticing.
- api_token came back as VALUE_SPECIFIED_IN_NO_LOG_PARAMETER — in the invocation echo, automatically, because of no_log=True. That censoring happens for every caller of your module without them doing anything.
- Validation failed before any code ran. The missing-argument and invalid-choice errors came from AnsibleModule itself, not from your logic — which is the argument spec earning its keep.
- A module is just a Python script that reads JSON and prints JSON. Running it directly like this is the fastest possible development loop — no inventory, no SSH, no playbook. It is exactly the mechanism from Module 01 Part B6, seen from the inside.
Now try "_ansible_check_mode": true in the args and confirm your module reports changed: true without acting.
🎯 Interview questions — Module anatomy
Q. Walk me through the structure of a custom module.
Shebang, then DOCUMENTATION, EXAMPLES and RETURN as YAML strings — these are what ansible-doc renders and sanity tests validate. Then import AnsibleModule from ansible.module_utils.basic.
In run_module: define the argument_spec, instantiate AnsibleModule with supports_check_mode=True, then the three-step body — check current state and exit unchanged if it matches; handle check mode before acting; then make the change. Return with exit_json or fail_json.
The module communicates only through a single JSON document on stdout.
Q. How do you support check mode correctly?
supports_check_mode=True when constructing AnsibleModule, then guard the mutating code with if module.check_mode: module.exit_json(changed=True) after determining that a change is needed but before performing it.
Order matters absolutely: put the guard after the mutation and your module changes production during a dry run.
If a module declares supports_check_mode=False, Ansible skips it entirely in check mode rather than risking it.
Q. How do you handle a secret parameter?
no_log=True in the argument spec for that option. Ansible then replaces the value with VALUE_SPECIFIED_IN_NO_LOG_PARAMETER in all output, verbose logs and the invocation echo — automatically, for every caller, without them needing no_log on their task.
That automatic censoring is one of the concrete advantages of a module over a command task.
Q. Why must a module never print()?
Because it communicates with the control node through exactly one JSON document on stdout. Any extra output corrupts that and produces a confusing "failed to parse output" error.
Use module.warn() for messages, module.fail_json() for errors, and module.exit_json() for the result.
Q. How do you test a module during development?
Run it directly: python3 library/mymodule.py /tmp/args.json where the file contains {"ANSIBLE_MODULE_ARGS": {...}}. No inventory, no SSH, no playbook — the fastest possible loop, and it exercises the real argument validation.
Add "_ansible_check_mode": true to test the check-mode path.
Then ansible-test sanity, ansible-test units and ansible-test integration from inside the collection for the real suite.
Part C · The other plugin types
C1 · Where each type runs — the distinction that matters
Modules are the box: the only thing that travels and runs at the far end. Filters, lookups, templates and inventory plugins all happen at your table, on the control node. That is why lookup('file', …) reads your filing cabinet and not the server's — the same point Module 04 A2 made about templates, and the single most common misunderstanding about how Ansible executes.
Diagram source
flowchart TD
A["Plugin types"] --> B["Run on the CONTROL NODE"]
A --> C["Run on the MANAGED NODE"]
B --> B1["filter - transform data in a template"]
B --> B2["lookup - fetch data from files, APIs, env"]
B --> B3["test - is x valid?"]
B --> B4["callback - format output, send notifications"]
B --> B5["inventory - build the host list"]
B --> B6["action - wraps a module, can do local work first"]
C --> C1["module - the actual work, pushed and executed"]
style B fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
style C1 fill:#FEF3C7,stroke:#D97706,stroke-width:2pxC2 · Filter plugins — the easiest and most useful
# plugins/filter/network.py (or filter_plugins/network.py beside a playbook)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
def to_cidr(netmask):
"""255.255.255.0 -> 24"""
return sum(bin(int(x)).count('1') for x in netmask.split('.'))
def env_short(environment):
"""production -> prd, staging -> stg"""
mapping = {'production': 'prd', 'staging': 'stg', 'development': 'dev'}
return mapping.get(environment, environment[:3])
class FilterModule(object):
def filters(self):
return {
'to_cidr': to_cidr,
'env_short': env_short,
}- ansible.builtin.debug:
msg: "{{ '255.255.255.0' | mycompany.platform.to_cidr }}" # -> 24
# or unqualified when in filter_plugins/ beside the playbook:
# msg: "{{ '255.255.255.0' | to_cidr }}"C3 · Lookup plugins
# plugins/lookup/cmdb.py
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
name: cmdb
short_description: Look up a host attribute from the internal CMDB
description:
- Queries the internal CMDB API and returns the requested attribute.
options:
_terms:
description: Hostnames to look up.
required: true
'''
from ansible.plugins.lookup import LookupBase
from ansible.errors import AnsibleError
class LookupModule(LookupBase):
def run(self, terms, variables=None, **kwargs):
self.set_options(var_options=variables, direct=kwargs)
results = []
for term in terms:
try:
results.append(self._query_cmdb(term))
except Exception as e:
raise AnsibleError("CMDB lookup failed for %s: %s" % (term, e))
return results
def _query_cmdb(self, hostname):
return {"owner": "platform-team", "tier": "web"}- ansible.builtin.debug:
msg: "{{ lookup('mycompany.platform.cmdb', inventory_hostname) }}"And it runs on the control node, once per host when used inside a task that loops over hosts. On a large fleet that is a lot of API calls, which is why the Module 07 advice applies: fetch once with run_once and delegate_to: localhost, then read from hostvars.
C4 · Action plugins versus modules
That is exactly why template needs an action plugin as well as a module. The action plugin renders the template locally, where the .j2 file and your variables actually live, and then hands the finished text to copy to deliver. One job at your table, one job at theirs — and that split is what an action plugin is.
| Type | Behaviour |
|---|---|
| Module | Pushed to the target and executed there. Has no access to control-node files |
| Action plugin | Runs on the control node first, can do local work, then usually calls a module on the target |
You will rarely write an action plugin. Being able to explain why template needs one is the answer that shows you understand the architecture.
C5 · Where plugins live
# Beside a playbook - quick and local
project/
library/ <- modules
filter_plugins/
lookup_plugins/
callback_plugins/
# Inside a role - scoped to that role
roles/myrole/
library/
filter_plugins/
# Inside a collection - the modern, shareable way ⭐
mycompany/platform/plugins/
modules/
module_utils/
filter/
lookup/
action/
callback/🧪 Exercise C5.1 — Write and use a filter plugin in five minutes
mkdir -p filter_plugins
cat > filter_plugins/custom.py <<'PYEOF'
from __future__ import absolute_import, division, print_function
__metaclass__ = type
def to_cidr(netmask):
return sum(bin(int(x)).count('1') for x in netmask.split('.'))
def env_short(environment):
return {'production': 'prd', 'staging': 'stg'}.get(environment, environment[:3])
class FilterModule(object):
def filters(self):
return {'to_cidr': to_cidr, 'env_short': env_short}
PYEOF
cat > filtertest.yml <<'EOF'
---
- hosts: localhost
gather_facts: false
vars:
netmask: "255.255.254.0"
env: "production"
tasks:
- ansible.builtin.debug:
msg:
- "{{ netmask }} is a /{{ netmask | to_cidr }}"
- "{{ env }} short name is {{ env | env_short }}"
- "chained: {{ env | env_short | upper }}"
EOF
ansible-playbook filtertest.yml
ansible-doc -t filter -l 2>/dev/null | grep -E 'to_cidr|env_short' || echo "not documented"✅ Expected result — click to reveal
ok: [localhost] => {
"msg": [
"255.255.254.0 is a /23",
"production short name is prd",
"chained: PRD"
]
}not documentedTwelve lines of Python and it works immediately — no installation, no collection, no restart. Ansible discovers filter_plugins/ beside the playbook automatically.
The third message shows filters chain with built-ins, because a custom filter is just a Python function in the same pipeline as upper. There is nothing special about it once registered.
Note the second command returned nothing. Plugins in filter_plugins/ beside a playbook are not documented by ansible-doc and have no FQCN — so two projects each defining to_cidr would collide, exactly the ambiguity Module 01 Part A4 described.
That is the argument for putting it in a collection as plugins/filter/: you gain mycompany.platform.to_cidr, versioning, and documentation. Start here for speed; move it there once you keep it.
🎯 Interview questions — Plugin types
Q. Which plugin types run on the control node and which on the target?
Only modules run on the managed node. Everything else — filter, lookup, test, callback, inventory, action, connection, strategy — runs on the control node.
That is why lookup('file', ...) reads the control node's filesystem and why a filter can never inspect a remote host.
Q. What is the difference between an action plugin and a module?
A module is pushed to the target and executed there, with no access to control-node files. An action plugin runs on the control node first, can do local work, and usually then invokes a module on the target.
template is the canonical example: its action plugin renders the Jinja2 locally — where the template file and your variables live — then hands the finished text to the copy module. A pure module could not do that.
Q. When is a filter plugin the right answer instead of a module?
Whenever the task is transforming a value rather than changing system state. No idempotency concerns, no check mode, no remote execution — about fifteen lines of Python, and unit-testable as a plain function.
A large share of "we need a custom module" requests are really filter plugins.
Q. What is the contract for a lookup plugin's return value?
It must return a list, even for a single term. Forgetting that produces confusing results downstream.
It also runs on the control node once per host when used in a per-host task, so on a large fleet you fetch once with run_once and delegate_to: localhost and read the result from hostvars.
Part D · Testing and distribution
D1 · ansible-test
Run-it-twice is the most valuable test you can write for a module, and it catches the commonest real bug by far: acting unconditionally instead of checking the current state first. It is the six eggs from Module 01, now as a test you can actually run in CI.
Run from inside the collection directory:
cd ~/collections/ansible_collections/mycompany/platform
ansible-test sanity --docker default # ⭐ docs, imports, pep8, validate-modules
ansible-test sanity --docker --test validate-modules
ansible-test units --docker default # unit tests under tests/unit/
ansible-test integration --docker default # real runs under tests/integration/
ansible-test integration tenant --docker # one target only| Test type | Checks |
|---|---|
| sanity | ⭐ DOCUMENTATION is valid and matches the argument_spec, imports work, PEP8, no shebang mistakes. Cheapest and catches the most |
| units | Your functions in isolation, with the API mocked |
| integration | The module actually running, including an idempotency check — run twice, assert changed=false the second time |
# tests/integration/targets/tenant/tasks/main.yml
---
- name: Create a tenant
mycompany.platform.tenant:
name: test-tenant
api_token: "{{ test_token }}"
register: first
- name: Create the same tenant again
mycompany.platform.tenant:
name: test-tenant
api_token: "{{ test_token }}"
register: second
- name: Assert idempotency
ansible.builtin.assert:
that:
- first is changed
- second is not changed
fail_msg: "Module is not idempotent - second run reported changed"D2 · Documentation is tested, so write it properly
ansible-doc mycompany.platform.tenant # ⭐ renders your DOCUMENTATION block
ansible-doc -s mycompany.platform.tenant # ⭐ paste-ready task snippet
ansible-doc -t filter mycompany.platform.to_cidrPart E · Putting it together
E1 · Production practice
| Habit | Why |
|---|---|
| Search the module index before writing anything | Nine times out of ten it already exists, and yours will be worse maintained |
| Filter plugin for data transformation, module for state change | A filter is fifteen lines with no idempotency or check-mode concerns |
| Check current state first, then check mode, then act | Wrong order means your module modifies production during a dry run |
| supports_check_mode=True and honour it | Declaring False makes Ansible skip the module in --check entirely |
| no_log=True on every secret parameter | Censors it automatically for every caller, in output and -vvv |
| Never print() — exit_json, fail_json, warn | Stray stdout corrupts the single JSON document and breaks the parser |
| fail_json rather than an uncaught exception | Structured, readable failure instead of a Python traceback |
| Write the run-twice idempotency integration test | Catches the most common real bug in a hand-written module |
| Keep DOCUMENTATION in step with argument_spec | validate-modules fails otherwise, and ansible-doc starts lying to users |
| Put anything you keep in a collection, not library/ | FQCN, versioning, ansible-doc, no name collisions |
| Develop by running the module directly with an args JSON file | No inventory, no SSH — the fastest possible loop |
E2 · Capstone exercise
Brief. Write a module feature_flag that manages a flag in a JSON file on the target:
- Options: name (required), enabled (bool, default false), path (path, default /etc/app/flags.json), api_token (required, secret)
- Must be idempotent — no change when the flag already has the desired value
- Must support check mode correctly
- The token must never appear in any output
- Must fail cleanly, not with a traceback, if the file is unreadable
- Must return the resulting flag state
- Write the integration test that proves requirement 2
✅ Model answer — attempt it first, then click
#!/usr/bin/python
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: feature_flag
short_description: Manage a feature flag in a JSON file
version_added: "1.0.0"
description:
- Sets a named feature flag to enabled or disabled in a JSON file on the target.
options:
name:
description: Flag name.
required: true
type: str
enabled:
description: Desired flag state.
type: bool
default: false
path:
description: Path to the JSON flags file.
type: path
default: /etc/app/flags.json
api_token:
description: Token used to report the change to the platform API.
required: true
type: str
author:
- Zaeem Mazhar (@zaeem)
'''
EXAMPLES = r'''
- name: Enable the new checkout flow
mycompany.platform.feature_flag:
name: new_checkout
enabled: true
api_token: "{{ vault_platform_token }}"
'''
RETURN = r'''
flag:
description: The flag state after the run.
returned: always
type: dict
sample: {"name": "new_checkout", "enabled": true}
'''
import json
import os
from ansible.module_utils.basic import AnsibleModule
def read_flags(module, path):
if not os.path.exists(path):
return {}
try:
with open(path, 'r') as fh:
return json.load(fh)
except (IOError, OSError, ValueError) as e: # requirement 5
module.fail_json(msg="Cannot read flags file %s: %s" % (path, str(e)))
def run_module():
argument_spec = dict( # requirement 1
name=dict(type='str', required=True),
enabled=dict(type='bool', default=False),
path=dict(type='path', default='/etc/app/flags.json'),
api_token=dict(type='str', required=True, no_log=True), # requirement 4
)
module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True)
name = module.params['name']
enabled = module.params['enabled']
path = module.params['path']
flags = read_flags(module, path)
current = flags.get(name)
result = dict(changed=False, flag={'name': name, 'enabled': enabled}) # requirement 6
# requirement 2 - already correct, do nothing
if current == enabled:
result['flag']['enabled'] = current
module.exit_json(**result)
# requirement 3 - a change is needed; in check mode stop here
if module.check_mode:
result['changed'] = True
module.exit_json(**result)
flags[name] = enabled
try:
dirname = os.path.dirname(path)
if dirname and not os.path.isdir(dirname):
os.makedirs(dirname)
with open(path, 'w') as fh:
json.dump(flags, fh, indent=2, sort_keys=True)
except (IOError, OSError) as e: # requirement 5
module.fail_json(msg="Cannot write flags file %s: %s" % (path, str(e)), **result)
result['changed'] = True
module.exit_json(**result)
def main():
run_module()
if __name__ == '__main__':
main()Requirement 7 — the integration test:
---
- name: Set the flag
mycompany.platform.feature_flag:
name: test_flag
enabled: true
path: /tmp/test-flags.json
api_token: dummy
register: first
- name: Set the same flag again
mycompany.platform.feature_flag:
name: test_flag
enabled: true
path: /tmp/test-flags.json
api_token: dummy
register: second
- name: Check mode must not modify anything
mycompany.platform.feature_flag:
name: another_flag
enabled: true
path: /tmp/test-flags.json
api_token: dummy
check_mode: true
register: dry
- ansible.builtin.stat:
path: /tmp/test-flags.json
register: st
- ansible.builtin.assert:
that:
- first is changed
- second is not changed # requirement 2
- dry is changed # reports it WOULD change
- "'another_flag' not in (lookup('file', '/tmp/test-flags.json') | from_json)"
fail_msg: "feature_flag is not idempotent or check mode is broken"The six decisions:
- type='path' rather than str — Ansible expands ~ and resolves the path for you.
- no_log=True on api_token — requirement 4. Automatic, for every caller.
- The state check comes first, before the check-mode guard, before any write. Requirement 2 depends on that order.
- The check-mode guard sits after the state check and before the write. Reverse those two and requirement 3 fails silently in the worst possible way.
- fail_json inside except, not a bare raise — requirement 5. A traceback reaching the control node produces "module failed to parse output" rather than a usable error.
- sort_keys=True in json.dump — otherwise the file's key order can vary between runs, the content differs, and a different module reading it might report spurious changes. The Module 04 idempotency lesson applied to output you generate.
Verify:
echo '{"ANSIBLE_MODULE_ARGS": {"name":"f1","enabled":true,"path":"/tmp/f.json","api_token":"t"}}' > /tmp/a.json
python3 library/feature_flag.py /tmp/a.json # changed: true
python3 library/feature_flag.py /tmp/a.json # changed: false
echo '{"ANSIBLE_MODULE_ARGS": {"name":"f2","enabled":true,"path":"/tmp/f.json","api_token":"t","_ansible_check_mode":true}}' > /tmp/c.json
python3 library/feature_flag.py /tmp/c.json # changed: true, file untouched
cat /tmp/f.json # f2 must NOT be presentE3 · Command reference — everything from this module
Developing
# ⭐ run a module directly - no inventory, no SSH, fastest loop
echo '{"ANSIBLE_MODULE_ARGS": {"name":"x","state":"present"}}' > /tmp/args.json
python3 library/mymodule.py /tmp/args.json
# ⭐ exercise the check-mode path
echo '{"ANSIBLE_MODULE_ARGS": {"name":"x","_ansible_check_mode":true}}' > /tmp/c.json
python3 library/mymodule.py /tmp/c.json
python3 -c "import json,sys; json.load(open('/tmp/args.json'))" # validate the args JSON
ansible localhost -m mymodule -a "name=x" -M ./library # ⭐ ad-hoc with a module path
ansible-playbook test.yml -M ./library -vvv # ⭐ explicit module pathDocumenting and discovering
ansible-doc mycompany.platform.tenant # ⭐ render your DOCUMENTATION
ansible-doc -s mycompany.platform.tenant # ⭐ paste-ready snippet
ansible-doc -t filter -l # ⭐ every filter available
ansible-doc -t lookup -l
ansible-doc -t callback -l
ansible-doc -l mycompany.platform # ⭐ modules in your collectionTesting
cd ~/collections/ansible_collections/mycompany/platform
ansible-test sanity --docker default # ⭐ docs, imports, pep8
ansible-test sanity --docker --test validate-modules # ⭐ DOCUMENTATION vs argument_spec
ansible-test units --docker default
ansible-test integration --docker default
ansible-test integration tenant --docker # one target
ansible-test sanity --docker --python 3.11 # a specific interpreterPlugin locations
library/ modules, beside a playbook
filter_plugins/ ⭐ filters, beside a playbook - no FQCN, no ansible-doc
lookup_plugins/
callback_plugins/
roles/<role>/library/ scoped to one role
collections/.../plugins/ ⭐ modules/ module_utils/ filter/ lookup/ action/ callback/The module skeleton, condensed
from ansible.module_utils.basic import AnsibleModule
module = AnsibleModule(
argument_spec=dict(
name=dict(type='str', required=True),
state=dict(type='str', default='present', choices=['present','absent']),
token=dict(type='str', required=True, no_log=True), # ⭐ secrets
),
supports_check_mode=True, # ⭐
required_if=[('state','present',['size'])],
mutually_exclusive=[['name','id']],
)
module.params # ⭐ validated, type-cast
module.check_mode # ⭐ bool
module.run_command([...], check_rc=True)
module.warn("...")
module.exit_json(changed=True, thing={...}) # ⭐
module.fail_json(msg="...") # ⭐ never raise1. Inspect current state. Matches desired? -> exit_json(changed=False)
2. if module.check_mode: -> exit_json(changed=True)
3. Make the change -> exit_json(changed=True)Steps 1 and 2 in that order are what make the module idempotent and check-mode safe. Everything else is detail.
E4 · Official documentation
| Link | Covers |
|---|---|
| Developing modules | The full walkthrough, from skeleton to first run |
| Module best practices | Conventions, return values, error handling |
| Module documentation format | DOCUMENTATION, EXAMPLES, RETURN syntax |
| Developing plugins | Filter, lookup, callback, action, inventory plugins |
| AnsibleModule reference | argument_spec, run_command, exit_json, fail_json |
| Testing collections with ansible-test | sanity, units, integration |
| Sanity tests | Every sanity test including validate-modules |
| Conventions and pitfalls | What reviewers reject when a module is submitted upstream |
E5 · Self-assessment
1. Before writing a module, what four things do you check?
Does a module already exist (search the index). Is it an HTTP API — then uri plus a role. Is it data transformation — then a filter plugin. Is it fetching data on the control node — then a lookup plugin.
Only after all four does a module become the right answer, and then mainly because you need idempotency and check-mode support.
2. State the three-step module body, in order.
Inspect current state and exit_json(changed=False) if it already matches. Then, if a change is needed, if module.check_mode: exit_json(changed=True). Only then make the change.
Reversing steps 2 and 3 means the module modifies production during a dry run.
3. What does no_log=True in an argument spec do?
Replaces that value with VALUE_SPECIFIED_IN_NO_LOG_PARAMETER in all output, verbose logs and the invocation echo — automatically, for every caller, without them needing no_log on their task. It is one of the concrete advantages of a module over command.
4. Why must a module never print()?
It communicates through exactly one JSON document on stdout. Anything else corrupts it and yields "module failed to parse output". Use module.warn(), module.fail_json(), module.exit_json().
5. Which plugin types run where?
Only modules run on the managed node. Filter, lookup, test, callback, inventory, action, connection and strategy plugins all run on the control node.
That is why lookup('file', ...) reads the control node's filesystem.
6. Why does template need an action plugin?
Because the Jinja2 template file and your variables live on the control node, and a module executes on the target. The action plugin renders locally, then hands the finished text to the copy module for transfer.
7. What must a lookup plugin return?
A list, always, even for a single term. And it runs on the control node once per host in a per-host task, so on a large fleet fetch once with run_once and delegate_to: localhost, then read from hostvars.
8. What does validate-modules check, and why does it matter?
That the DOCUMENTATION block matches the real argument_spec — every option documented, types agreeing, defaults consistent.
It catches the most common maintenance failure: a parameter added without documentation, so ansible-doc starts lying to the module's users.
9. Write the most valuable integration test for a module.
Run it, run it again, assert first is changed and second is not changed. That is idempotency turned into an executable check, and it catches the commonest real bug — acting unconditionally instead of inspecting state first.
Extend it with a check_mode: true run plus a stat/content assertion proving nothing was actually modified.
10. Where should a plugin live, and what do you lose in the alternatives?
In a collection under plugins/. Beside a playbook in library/ or filter_plugins/ works and is fine for a one-off, but you get no FQCN, no ansible-doc, no versioning, and two projects defining the same filter name will collide.
You have written roles, collections and now modules. Module 11 covers proving they work: ansible-lint, yamllint, Molecule scenarios, idempotence testing, and wiring it all into a pipeline.
📚 Sources for the interview questions
Behaviour verified against the current developing modules guide and developing plugins guide.
Question selection cross-referenced against publicly published 2026 Ansible interview question sets:
- Spacelift — 50+ Top Ansible Interview Questions & Answers for 2026
- GeeksforGeeks — Top 50+ Ansible Interview Questions and Answers
- Vinsys — Top 30 Ansible Interview Questions and Answers 2026
- K21 Academy — Ansible Interview Questions & Answers 2026
- Hirist — Top 25+ Ansible Interview Questions and Answers 2026
Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.