skip to content

You are writing a custom Ansible module in Python. What contract must it satisfy to behave like a first-class module?

level: seniorimportance: should knowfreq 32%

answer

  1. a program, not an imported library
  2. stdout is the protocol
  3. declare inputs in argument_spec
  4. compare before you act
  5. guard mutations with check_mode

basics

~20 s

A custom Ansible module must declare its inputs through an argument_spec on AnsibleModule, compare current state with desired state, act only when they differ, honour check mode, and exit by printing JSON via exit_json with an accurate changed flag or fail_json with a message.

solid answer

~40 s

The module is a Python program that Ansible ships to the managed host and runs there, so it must be self-contained and speak the protocol. In practice: import `AnsibleModule` from `ansible.module_utils.basic`, construct it with an `argument_spec` declaring each parameter's type, whether it is required, its default, and `no_log=True` for secrets; then read `module.params`. The logic must be idempotent — inspect the real current state, compare with what was asked, and return `changed=False` without touching anything when they already match. Support check mode by passing `supports_check_mode=True` and guarding every mutation with `if not module.check_mode`. Exit only through `module.exit_json(changed=..., ...)` or `module.fail_json(msg=...)` — never `print` or `sys.exit`, because the controller parses stdout as JSON. Ship it in a `library/` directory next to the playbook or role, or in a collection under `plugins/modules/`.

code

python · 36 lines
python
#!/usr/bin/python
from ansible.module_utils.basic import AnsibleModule


def main():
    module = AnsibleModule(
        argument_spec=dict(
            path=dict(type='str', required=True),
            content=dict(type='str', required=True),
        ),
        supports_check_mode=True,
    )
    path = module.params['path']
    desired = module.params['content']

    try:
        with open(path) as handle:
            current = handle.read()
    except FileNotFoundError:
        current = None

    if current == desired:
        module.exit_json(changed=False, path=path)

    if not module.check_mode:
        try:
            with open(path, 'w') as handle:
                handle.write(desired)
        except OSError as exc:
            module.fail_json(msg='could not write %s: %s' % (path, exc))

    module.exit_json(changed=True, path=path)


if __name__ == '__main__':
    main()

go deeper

for a junior

Know that a custom module is a Python program Ansible runs on the target, that its inputs are declared in an argument_spec, and that it returns results by calling exit_json rather than printing them.

for a middle

Explain the full contract: spec validation, comparing current state before acting, the changed flag, fail_json for errors, and the library/ or collection location where Ansible finds the file.

for a senior

Demonstrate the discipline — honest check-mode support, no_log on secrets, guarded imports for target-side dependencies, diff output, and documentation strings so ansible-doc works. Be ready to debug a MODULE FAILURE parse error.

for a principal

Own the decision of what becomes a supported module at all: the maintenance and testing burden, packaging and versioning it in a collection, and how consumers pin it so a module change cannot silently alter every playbook that uses it.

## What Ansible expects from the file A module is not a library that Ansible imports. It is a program: Ansible bundles the file plus the `module_utils` it imports into a payload, copies it to the managed host, executes it with the remote Python interpreter, and parses whatever it printed on stdout as a single JSON document. Everything about the contract follows from that. - **Stdout is the protocol.** Any stray `print()`, warning, or debug output corrupts the JSON and produces the infamous "MODULE FAILURE ... could not parse output". Diagnostics belong in the returned data or in `module.warn()`. - **The exit is structured.** `module.exit_json(**result)` for success and `module.fail_json(msg=...)` for failure. Both serialise and terminate; you do not call `sys.exit` yourself. - **It runs remotely.** No access to the controller's filesystem, no controller-side variables beyond the parameters passed in, and any third-party Python import must exist on the *target*. The polite pattern is a guarded import that produces a clear `fail_json` rather than a traceback. ## The argument spec ```python module = AnsibleModule( argument_spec=dict( name=dict(type='str', required=True), state=dict(type='str', default='present', choices=['present', 'absent']), api_token=dict(type='str', required=True, no_log=True), timeout=dict(type='int', default=30), ), supports_check_mode=True, ) ``` The spec is validation, documentation and safety in one place. `type` coerces and rejects bad input before your code runs. `choices` enumerates the legal values. `default` removes a class of `None` handling. `no_log=True` keeps a secret out of the task output and out of logs — omitting it on a token parameter is a genuine security defect, since results are printed and often shipped by a callback. `AnsibleModule` also accepts `required_if`, `required_together` and `mutually_exclusive` for cross-parameter rules, so those checks do not become hand-written `if` ladders. ## Idempotency is the whole point The reason to write a module instead of wrapping a CLI in `ansible.builtin.command` is that a module can compare state. The shape is always the same: 1. Read the current state of the resource from the host or its API. 2. Compute the desired state from `module.params`. 3. If they match, `exit_json(changed=False)` and stop. 4. Otherwise, if not in check mode, make the change. 5. `exit_json(changed=True, ...)` with useful return values. A module that always returns `changed=True` has thrown away its reason to exist. Returning the before/after values as `diff` data is the polish that makes `--diff` useful. ## Check mode Declaring `supports_check_mode=True` is a promise: when `module.check_mode` is true the module must predict its result without mutating anything. Steps 1-3 above run normally; step 4 is skipped; step 5 reports the change that *would* have happened. Getting this wrong is worse than not supporting check mode at all, because operators trust `--check` runs. If the module genuinely cannot predict safely, leave `supports_check_mode` at its default and Ansible will skip the task under `--check`. ## Where the file goes Ansible finds custom modules in a `library/` directory adjacent to the playbook, in a role's own `library/`, in paths listed by the `library` setting in `ansible.cfg` or the `ANSIBLE_LIBRARY` environment variable, or — the modern, distributable form — inside a collection at `plugins/modules/`. The collection form is what you want once more than one repository needs the module, because it can be versioned and depended on rather than copy-pasted. Modules also carry documentation as module-level `DOCUMENTATION`, `EXAMPLES` and `RETURN` strings in YAML. That is not decoration: it is what `ansible-doc <module>` prints, and for anything shared it is the difference between a module people can use and one only its author can. ## What interviewers are actually probing They want to know whether you understand that a module is a contract, not a script. The tells of a weak answer: printing results instead of returning them; hard-coding `changed=True`; ignoring check mode; putting a secret parameter in the spec without `no_log`; assuming the module can read a file from the control node. The tell of a strong one is that you frame the whole thing as "compare, act only on difference, report honestly" — the same contract the built-in modules honour, which is what makes a playbook converge cleanly.

  • Your custom module fails with a JSON parse error on the controller. What is the most likely cause?
    Something wrote to stdout that was not the final JSON document — a leftover `print()`, a library that logs to stdout, or a traceback from an unguarded exception. The controller reads the module's entire stdout as one JSON object, so any extra bytes break it. Route diagnostics into the returned data or `module.warn()`, and convert exceptions into `fail_json` calls.
  • Why does no_log matter on a parameter, and what does it not protect?
    `no_log=True` makes Ansible replace that value with a placeholder in task output, verbose logs and callback data, so a token does not end up in CI logs. It does not encrypt anything, does not stop your own module from returning the secret in its result, and does not protect the value where it is stored — that is what Vault and an external secret store are for.
  • When should you not support check mode?
    When the module genuinely cannot determine the outcome without performing the action — for example, invoking a third-party operation with no read or dry-run equivalent. Leaving `supports_check_mode` false makes Ansible skip the task under `--check`, which is honest. Claiming support and then either mutating anyway or reporting a guess is worse, because operators treat a check run as safe and as a prediction.
  • How do you handle a Python dependency your module needs on the managed host?
    Import it inside a try/except at module scope, record whether it succeeded, and call `fail_json` with a clear message naming the missing library if a task actually needs it. That turns an unreadable remote traceback into an actionable error. The dependency itself must be installed on the target, since the module executes there, not on the controller.

saying these in an interview costs you the question

  • Prints results to stdout instead of exit_json
  • Hard-codes changed=True on every run
  • Skips argument_spec and reads sys.argv directly
  • Ignores check mode but claims to support it
  • Puts an API token in the spec without no_log

context