Plugins: where the code runs is half the design
Summary in one line
There are several places to extend Ansible, but only one criterion separates them — filters, tests, and lookups run on the controller that executes the playbook, while modules are copied to the target server and run there. Almost everything about what to put where follows from this one line.
Why this is needed
As a playbook grows, every team ends up with the same kind of spot. Polishing a service name so it can be used as a file name, sorting port numbers into classes, looking up a value in a table that lives somewhere inside the company. When the standard filters do not cover it, people do one of two things.
The first is stretching Jinja2 by force. You get a line such as {{ name | lower | replace(' ', '-') | replace('.', '-') | replace('_', '-') | trim('-') }}, and six months later nobody wants to touch that line. You cannot test it either — that logic exists only inside the playbook and there is no way to call it on its own.
The second is dropping down to the shell. You write shell: echo {{ name }} | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9]/-/g' and capture the result with register. With this one line, everything Ansible gave you disappears. There is no idempotency (it is always changed), it is skipped in check mode, you start assuming the target has tr and sed, and there is one more round trip. You are spending an SSH round trip on computing a value.
Plugins are for this spot. Do the work of computing values on the controller in Python, and do not touch the target. Then that logic gathers in a single file, gets a name, and can be tested on its own.
How it works
Ansible has more than ten kinds of plugins, but the ones people who write playbooks usually end up making themselves are four.
| Kind | How to call it | Where it runs | What it returns |
|---|---|---|---|
| Filter | 값 | 이름 |
Controller | Any value |
| Test | 값 is 이름 |
Controller | True or false |
| Lookup | lookup('이름', 인자) |
Controller | A list |
| Module | A key of the task | Target | JSON (including changed) |
In the table, the Korean parts of the call forms are placeholders for a value, a name, and an argument. The first three are called when the template engine produces a value. So nothing needs to be installed on the target, and SSH round trips do not increase. In exchange, they cannot see the target's state — a filter cannot know how much disk space is left on the target. That is a job for facts or modules.
Modules are the opposite. The file is copied to the target and run with the target's Python. So they can change state, and being able to change it means they must take responsibility for idempotency and check mode themselves.
Directory and class conventions
Ansible does not ask you to register things in a configuration file. Instead it finds them by location and name.
플레이북 옆:
filter_plugins/ → class FilterModule 의 filters() 가 돌려주는 사전
test_plugins/ → class TestModule 의 tests() 가 돌려주는 사전
lookup_plugins/ → class LookupModule(LookupBase) 의 run()
library/ → 모듈. 파일 이름이 곧 모듈 이름
컬렉션 안:
plugins/filter/ plugins/test/ plugins/lookup/ plugins/modules/
Two things often catch people here. One is that the file name is free but the class name is a convention. If it is not FilterModule, it is simply not found, with no error at all. The other is that "next to the playbook" is meant literally. This search is not applied to ad-hoc commands (ansible -m debug -a ...), so it is common to try a new filter ad hoc and conclude "it doesn't work."
If you put it inside a collection, the location changes. It is plugins/filter/, not filter_plugins/, and when you call it you use an FQCN such as 네임스페이스.이름.필터 (namespace, name, and filter). A collection must live under the three levels <검색경로>/ansible_collections/<네임스페이스>/<컬렉션이름>/ (search path, namespace, and collection name), and if even one level is off, it is likewise silently not found.
What a filter must observe
A filter is a function that Jinja2 calls when it produces a value. So it must be a pure function — it must always give the same value for the same input and must not touch the outside world. The reason is not performance but predictability. It is not fixed when or how many times a template will be evaluated. In a task with a condition, it may not be evaluated at all, and it may be evaluated separately on each of several hosts. If a filter writes a file or reads the time, the result differs from run to run, and that task can never be idempotent.
When it meets invalid input, cut it off with AnsibleFilterError. A filter that quietly returns an empty string is worse — the wrong value goes straight into the configuration file and nobody reports it.
Incidents that lookups cause
A lookup runs on the controller. So lookup('file', '/etc/app/secret') reads that file on the controller, not that file on the target server. Even though the documentation states this point in bold, it is the spot people trip on most often. Things that work on a developer's laptop but fail on a CI runner because the file is missing, or conversely a file from the CI runner being deployed to the target, come from here.
A lookup always returns a list. That is because with_ loops were originally built on top of lookups. If you need only a single value, add | first or use lookup() instead of query().
When to use a module instead of a filter
The criteria are simple.
- Do you need to read the target's state? → Module (or facts). A filter cannot see it.
- Do you need to change the target's state? → Module. The moment a filter writes a file, it is no longer a pure function.
- Do you only transform a value? → Filter.
- Can the answer be given as a single true or false? → Test. It is used as it is in
when:and inselect/reject. - Do you need to fetch data from the controller side? → Lookup.
What it looks like in the field
First, a single filter becomes the team standard. Once you create a filter that turns a service name into a slug, from then on Kubernetes labels, file names, and log tags are all produced by the same rule. The real value of this work is that the rule gathers in one place in the code. When you need to change the rule, there is one place to fix.
Second, you become able to test. A plugin is just a Python file, so you can call it directly from a Python testing tool. You cannot do that with a line of Jinja2 inside a playbook. The more complex the logic gets, the bigger this difference becomes.
Third, names change when you move to a collection. You first put it in filter_plugins/, and when it comes to be used across several repositories you move it to a collection, at which point all the call sites change to FQCNs. If you put a team prefix in the name from the start, collisions are fewer when you move.
Fourth, custom modules are needed less often than you might think. If the job is calling an in-house API, the uri module is usually enough. A custom module is worth writing when you are dealing with "a complex state that must be changed only once even if called many times." In that case, always declare supports_check_mode — if you do not, in --check that task is not failed but silently skipped. Someone who runs check mode to see the plan never finds out what that task would do.
References
- Plugin development guide: https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html
- Adding modules and plugins locally: https://docs.ansible.com/ansible/latest/dev_guide/developing_locally.html
- Lookup plugins: https://docs.ansible.com/ansible/latest/plugins/lookup.html
- Collection directory structure: https://docs.ansible.com/ansible/latest/dev_guide/developing_collections_structure.html
- Module development guide: https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_general.html
What you will do in the next lab
You build all four kinds yourself and connect them in one playbook. You create a slug filter in filter_plugins/, add a second filter that takes two boundaries as arguments, create a test that returns only true or false in test_plugins/, and create a lookup in lookup_plugins/ that reads a JSON registry on the controller. You move the same filter inside a collection (plugins/filter/) and call it by FQCN, and finally you create a custom module in library/, run it on the target, and check that the second run gives changed=0 and that no file is created in check mode.