Ansible has around twenty plugin types. Four of them get written by
users, and choosing between them looks like a taxonomy problem.
It is not. It is one question:
Does this code need to run on the controller, or on the managed
node?
Answer that and the type follows. Get it wrong and you produce
automation that works perfectly on the developer’s laptop, works
perfectly in a play targeting localhost, and reads the wrong machine
in production — while reporting success.
The four types, by where they execute
Type
Runs on
Sees
Can change the target
Filter
Controller
Data you passed it
No
Lookup
Controller
The controller’s filesystem, environment, network
No
Module
Managed node
The managed node’s filesystem, processes, services
Yes
Action plugin
Controller, and usually dispatches a module to the node
Both, deliberately
Yes, via the module it dispatches
The first two are transformations and reads. The third is the only one
that acts on the host. The fourth is the seam between them, and it is
the one you almost never need.
The demonstration
A play targeting two remote hosts, with a lookup reading a file.
Read-only / Safelookupdemo.yml— READ-ONLY: debug prints and nothing else. Note the play targets two hosts in the documentation range 192.0.2.0/24, which is unreachable by design.
- name: A lookup in a play targeting remote hosts
hosts: web01.example.com,web02.example.com
gather_facts: false
tasks:
- name: Read a file with a lookup
ansible.builtin.debug:
msg: "{{ lookup('file', playbook_dir + '/marker.txt') }}"
Read-only / Safethe result against two unreachable hosts— Real output from ansible-core 2.21.3. Both hosts are in 192.0.2.0/24 and were never contacted. The file contents printed are the controller's, and the recap reports two successful hosts.
TASK [Read a file with a lookup] ***********************************************
ok: [web01.example.com] => {
"msg": "controller-copy"
}
ok: [web02.example.com] => {
"msg": "controller-copy"
}
PLAY RECAP *********************************************************************
web01.example.com : ok=1 changed=0 unreachable=0 failed=0
web02.example.com : ok=1 changed=0 unreachable=0 failed=0
Read that recap carefully. Two hosts that do not exist, that no packet
ever reached, both reported ok=1 and unreachable=0. The lookup ran
twice on the controller — once per host in the play — and read the
controller’s file both times.
The correct form, when the file is genuinely on the managed node:
Read-only / Safereading a remote file with a module— READ-ONLY: slurp reads a file and returns its contents base64-encoded; stat reads metadata. Both execute on the managed node.
- name: Read the file from the managed node
ansible.builtin.slurp:
src: /etc/app/version
register: remote_version
- name: Use it
ansible.builtin.debug:
msg: "{{ remote_version['content'] | b64decode | trim }}"
# Or, when you only need to know whether it exists:
- name: Check for the marker
ansible.builtin.stat:
path: /etc/app/version
register: marker
slurp returns base64 because the file may contain anything, including
bytes that are not valid text. The | b64decode is not ceremony; it is
the price of a module that can read any file.
Choosing, in order of cost
Work down. Stop at the first that fits.
Is it a pure transformation of data you already have? A filter. Cheapest by a wide margin: a plain Python function, no state, no idempotency, no changed value, no check mode.
Does it read something on the controller - a file, an environment variable, a secret manager, an API the controller can reach? A lookup. Still controller-side, still no change semantics.
Does it need to inspect or modify the managed node? A module. Now you owe the full contract from lesson 3.
Does it need to do controller-side work AND dispatch a module - copy a file into place before running something, or template on the controller then transfer? An action plugin. Read the built-in ones first; template and copy are action plugins and they are how you learn the pattern.
If two answers seem to fit, prefer the one further up this list. The cost difference between a filter and a module is not small.
Two cases that look ambiguous and are not
“It calls an API, so where does it run?” Whichever side has the
network route and the credentials. Most APIs are reachable from the
controller, so uri or a lookup is correct and no managed node is
involved at all — which is why so many cloud modules are effectively
controller-side work with delegate_to: localhost. If the API is only
reachable from the managed node, it must be a module.
“It reads a file, so it must be a lookup.” Only if the file is on
the controller. This is the confusion the demonstration above exists to
break, and the discriminating question is never “what does the code do”
but “which machine’s disk holds the bytes”.
Knowledge check
Knowledge check · 5 questions
Q1. A play targets 300 web servers and uses `{{ lookup("file", "/etc/app/version") }}` to read each host's installed version. What actually happens?
Q2. Which properties make a lookup used in place of a module hard to catch in review or testing? Select all that apply.
Q3. ansible.builtin.template is an action plugin: it renders on the controller and then transfers the result, which is why the managed node needs no Jinja installed.
Q4. The requirement is "given a list of interface dictionaries, produce the subset that have an IPv6 address". Which plugin type?
Q5. A team needs to parse a vendor configuration format both on the controller and on managed nodes. What is the best structure?
Passing score: 75%. Answers are checked in this browser.