Writing modules, debugging odd behaviour and upgrading ansible-core all need this model.
On the control node, Ansible parses the playbook, builds the host list, and for each task and host a worker process (one per fork) prepares the task: it templates the arguments with that host's variables, then hands it to an action plugin. Most modules use the generic normal action; some (template, copy, fetch) have action plugins doing controller-side work first, such as rendering the template locally.
The connection plugin (ssh, local, paramiko, winrm, kubectl...) moves code and data. Python modules are bundled with their module_utils into a single self-extracting AnsiballZ payload, sent to the target, executed with the remote Python, and return JSON (changed, failed, msg, data). Set ANSIBLE_KEEP_REMOTE_FILES=1 to keep the payload on the host for debugging.
So: modules run on targets; plugins (lookup, filter, callback, inventory, connection, become, cache, strategy) run on the controller.
Writing a module: a Python file using AnsibleModule with an argument_spec, supporting check_mode, returning changed truthfully. Put it in a collection's plugins/modules/ (or a role's library/).
ansible-core 2.19 reworked templating with data tagging: values carry their origin (and trust), so error messages point to the exact file and line. Stricter rules came with it: conditionals must be booleans, templates inside untrusted data aren't re-evaluated, and some lenient behaviour is gone. Read the porting guides before upgrading across 2.18 to 2.19 and later.
Example
#!/usr/bin/python
# library/greeting_file.py: a minimal idempotent module
from pathlib import Path
from ansible.module_utils.basic import AnsibleModule
def main():
module = AnsibleModule(
argument_spec={"path": {"type": "path", "required": True},
"name": {"type": "str", "default": "world"}},
supports_check_mode=True)
path, text = Path(module.params["path"]), f"hello {module.params['name']}\n"
changed = not path.exists() or path.read_text() != text
if changed and not module.check_mode:
path.write_text(text)
module.exit_json(changed=changed, path=str(path))
if __name__ == "__main__":
main()
# - greeting_file: {path: /tmp/greet.txt, name: ana} -> changed, then okRemember
- Templating and action plugins run on the controller; modules run on targets
- Python modules travel as AnsiballZ payloads and return JSON
- 2.19+ data tagging: stricter, better-located templating errors
Watch out for
- Writing a custom module that always returns changed=True, breaking handlers and idempotence checks.