How tasks execute: modules, plugins, templating

Ansible · Expert · 14 min

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 ok

Remember

  • 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.

Check yourself

  1. Where does a filter plugin like to_nice_yaml run?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.ansible.com/ansible/latest/dev_guide/developing_modules_general.html

Builds on

Unlocks

Nothing builds on this yet.