Turn the shell workaround into a real plugin
Goal
You create Jinja2 filters, tests, lookups, and a custom module yourself and call them from a playbook. You confirm in code where each of the four runs, and what that difference in location makes possible and impossible.
Why it matters
When a playbook grows, every team ends up with the same kind of spot — polishing names, classifying numbers, and looking up a table somewhere. When the standard filters do not cover it, people patch it with shell and sed, and at that moment the task loses both idempotency and check mode. Plugins are for that spot. But you cannot just put them anywhere; where it runs is half of the design. Filters, tests, and lookups run on the controller, so nothing needs to be installed on the target, but they cannot see the target's state; modules are copied to the target and run there, so they can change state, but they must take responsibility for idempotency and check mode themselves. This lab has you cross that boundary four times and check it by hand.
Steps
- Create
/root/ansplug/ansible.cfgand set the default inventory to./inventory/hosts.ini. In/root/ansplug/inventory/hosts.ini, list three groups — inweb,web1(svc_port 8080, svc_nameWeb Front 01) andweb2(9090,Web Front 02); indb,db1(5432,Main DB!); inedge,cache1(443,Edge Cache). All four hosts haveansible_host=127.0.0.1andansible_port=2222, andansible_userin[all:vars]isroot. Then runansible all -m ansible.builtin.ping -oand save the standard output and standard error together to/root/ansplug/out/ping.txt. - Create
/root/ansplug/filter_plugins/labfilters.pyand register a singleslugifyfilter. This filter lowercases a string, replaces every non-alphanumeric character with-, collapses consecutive-into one, and strips-from both ends. If a non-string value comes in, it raisesAnsibleFilterError. Then create/root/ansplug/slug.yml, write the result of passing'Web Server 01!!'through this filter to/root/ansplug/out/slug.txt, and run it. - Add a
port_classfilter to the same/root/ansplug/filter_plugins/labfilters.py.port_class(value, privileged_below=1024, dynamic_from=49152)returnssystemif the port is less thanprivileged_below,dynamicif it isdynamic_fromor more, anduserif it is in between, and raisesAnsibleFilterErrorif it is not an integer. Then create/root/ansplug/ports.ymland run it so that/root/ansplug/out/ports.txtgets the four inventory hosts in ascending name order, one line each, as<호스트> <포트> <분류>(host, port, class). - Create
/root/ansplug/test_plugins/labtests.pyand register areserved_porttest — it is true if the port number is less than 1024. Then create/root/ansplug/reserved.ymland run it so that/root/ansplug/out/reserved.txtgets the four hosts in ascending name order, one line each, as<호스트> <포트> <reserved|free>(host and port). The judgment must always be called in the formis reserved_port. - Write
{"web": "seoul-a", "db": "seoul-b", "edge": "seoul-c"}in/root/ansplug/registry.json. Create/root/ansplug/lookup_plugins/labregistry.pyand define alabregistrylookup — it takes a key and returns the registry's value, lets you specify a different file with theregistry=keyword argument (the default is/root/ansplug/registry.json), and raisesAnsibleErrorif the file or the key is missing. Then use/root/ansplug/regions.ymlto write<그룹> <지역>(group and region) for the three groupsdb,edge, andweb, in this order, to/root/ansplug/out/regions.txt, and run it. - Place a collection in
/root/ansplug/collections/ansible_collections/labhub/site/— thenamespaceingalaxy.ymlislabhub, thenameissite, and the filter file goes underplugins/filter/(you can copy what you made in steps 2 and 3 as is). Addcollections_path = ./collectionsto/root/ansplug/ansible.cfg, then use/root/ansplug/fqcn.ymlto write the result of passing'Prod DB 02!!'tolabhub.site.slugifyinto/root/ansplug/out/fqcn.txt, and run it. - In
/root/ansplug/library/lab_marker.py, create a custom modulelab_marker— its arguments arepathandcontent; if the file content is already the same it ends withchanged=false, and if different it writes the file and ends withchanged=true. Declaresupports_check_mode=Trueand do not write in check mode. Run this module against thewebgroup with/root/ansplug/marker.ymlso that it writes one line with that host's name to/root/ansplug/out/<호스트이름>.marker(with the host name in place of the placeholder), then run the playbook twice and save the output of the second run to/root/ansplug/out/marker_run2.txt. - Use
/root/ansplug/report.ymlto write the four hosts in ascending name order, one line each, to/root/ansplug/out/report.txt— each line is<호스트> <이름슬러그> <포트> <포트분류> <reserved|free> <지역>(host, name slug, port, port class, reserved or free, and region), where the name slug issvc_namepassed throughslugify, the port class isport_class, the fifth column is thereserved_porttest, and the region is the value obtained by askinglabregistryfor that host's first group name. Run the playbook twice and save the output of the second run to/root/ansplug/out/report_run2.txt.
Notes
filter_plugins/,test_plugins/,lookup_plugins/, andlibrary/are found only if they are next to the playbook. That is why all the playbooks in this lab go directly under/root/ansplug.- sshd is running on 2222 of 127.0.0.1. The four hosts in the inventory differ only in name and all connect to the same server.
- The way to call each kind is different — a filter is
값 | 이름, a test is값 is 이름, a lookup islookup('이름', 인자), and a module is a key of the task. The Korean parts are placeholders for a value, a name, and an argument. - Common mistake: naming the class something other than
FilterModuleand then wondering "why can't it find it?" The file name is free but the class name is a convention. - Common mistake: trying a new filter with an ad-hoc command and deciding it does not work — the search of the neighboring directories applies only to playbooks.
- Common mistake: placing a collection without the
ansible_collections/level. It does not raise an error; it is simply not found. - Developing plugins · Adding modules and plugins locally · Lookup plugins · Collection structure · Developing modules
Set up an inventory that imitates four hosts
Create /root/ansplug/ansible.cfg and set the default inventory to ./inventory/hosts.ini. In /root/ansplug/inventory/hosts.ini, list three groups — in web, web1 (svc_port 8080, svc_name Web Front 01) and web2 (9090, Web Front 02); in db, db1 (5432, Main DB!); in edge, cache1 (443, Edge Cache). All four hosts have ansible_host=127.0.0.1 and ansible_port=2222, and ansible_user in [all:vars] is root. Then run ansible all -m ansible.builtin.ping -o and save the standard output and standard error together to /root/ansplug/out/ping.txt.
The sshd in this Pod runs on 2222 of 127.0.0.1 and supports key authentication. Even if you write several names in the inventory, they all connect to the same sshd, so you can imitate "several hosts." In an INI inventory, wrap a value in double quotes if it contains a space. Check the merged result with ansible-inventory --list.
The first filter — a pure function that runs on the controller
Create /root/ansplug/filter_plugins/labfilters.py and register a single slugify filter. This filter lowercases a string, replaces every non-alphanumeric character with -, collapses consecutive - into one, and strips - from both ends. If a non-string value comes in, it raises AnsibleFilterError. Then create /root/ansplug/slug.yml, write the result of passing 'Web Server 01!!' through this filter to /root/ansplug/out/slug.txt, and run it.
Ansible looks for a class named FilterModule in the Python files inside filter_plugins/, and uses the keys of the dictionary returned by that class's filters() as filter names. This directory is found only if it is next to the playbook — it is not found in ad-hoc commands. Because a filter runs on the controller, nothing needs to be installed on the target, which is all the more reason it must be a pure function without side effects.
A filter that takes arguments, and the place to cut off invalid input
Add a port_class filter to the same /root/ansplug/filter_plugins/labfilters.py. port_class(value, privileged_below=1024, dynamic_from=49152) returns system if the port is less than privileged_below, dynamic if it is dynamic_from or more, and user if it is in between, and raises AnsibleFilterError if it is not an integer. Then create /root/ansplug/ports.yml and run it so that /root/ansplug/out/ports.txt gets the four inventory hosts in ascending name order, one line each, as <호스트> <포트> <분류> (host, port, class).
A Jinja2 filter receives the value to the left of the pipe as its first argument, and what you write in parentheses becomes the following arguments — like {{ 3000 | port_class(2000, 4000) }}. If you put the defaults on the Python side, most calls finish without arguments, and only special places need to call it with different boundaries. In Python, True is a subtype of int, so it passes isinstance(x, int) — you have to filter out booleans first. To go through all hosts, use groups['all'] and hostvars[...].
The test plugin — a place that returns only true or false
Create /root/ansplug/test_plugins/labtests.py and register a reserved_port test — it is true if the port number is less than 1024. Then create /root/ansplug/reserved.yml and run it so that /root/ansplug/out/reserved.txt gets the four hosts in ascending name order, one line each, as <호스트> <포트> <reserved|free> (host and port). The judgment must always be called in the form is reserved_port.
A test differs from a filter in both class name and method name — they are TestModule and tests(). The directory is also test_plugins/. The way to call it is different too. A filter is 값 | 이름, a test is 값 is 이름, and a test must return only true or false. The reason for this distinction is that when: and select/reject are used on the assumption of tests — if you return a value that is not true or false, the meaning collapses right there.
The lookup — a place that reads a file on the controller
Write {"web": "seoul-a", "db": "seoul-b", "edge": "seoul-c"} in /root/ansplug/registry.json. Create /root/ansplug/lookup_plugins/labregistry.py and define a labregistry lookup — it takes a key and returns the registry's value, lets you specify a different file with the registry= keyword argument (the default is /root/ansplug/registry.json), and raises AnsibleError if the file or the key is missing. Then use /root/ansplug/regions.yml to write <그룹> <지역> (group and region) for the three groups db, edge, and web, in this order, to /root/ansplug/out/regions.txt, and run it.
A lookup is a LookupModule that inherits from LookupBase, and its entry point is run(self, terms, variables=None, **kwargs). terms is the list of arguments of lookup('이름', 첫째, 둘째), and it must return a list. What arrives in kwargs are keyword arguments such as registry=. The important thing is the fact that this code runs on the controller — the file a lookup reads must be in the place where the playbook is run, not on the target server.
Move the same filter into a collection and call it by FQCN
Place a collection in /root/ansplug/collections/ansible_collections/labhub/site/ — the namespace in galaxy.yml is labhub, the name is site, and the filter file goes under plugins/filter/ (you can copy what you made in steps 2 and 3 as is). Add collections_path = ./collections to /root/ansplug/ansible.cfg, then use /root/ansplug/fqcn.yml to write the result of passing 'Prod DB 02!!' to labhub.site.slugify into /root/ansplug/out/fqcn.txt, and run it.
The location of a collection is fixed as <검색경로>/ansible_collections/<네임스페이스>/<이름>/ (search path, namespace, and name) — if even one of these three levels is off, it is not found, with no error at all. Inside a collection, the location of a filter is plugins/filter/, not filter_plugins/. Tests go in plugins/test/, lookups in plugins/lookup/, and modules in plugins/modules/. To check whether it is being found properly, use ansible-config dump | grep COLLECTIONS.
A module runs on the target — idempotency and check mode are its responsibility
In /root/ansplug/library/lab_marker.py, create a custom module lab_marker — its arguments are path and content; if the file content is already the same it ends with changed=false, and if different it writes the file and ends with changed=true. Declare supports_check_mode=True and do not write in check mode. Run this module against the web group with /root/ansplug/marker.yml so that it writes one line with that host's name to /root/ansplug/out/<호스트이름>.marker (with the host name in place of the placeholder), then run the playbook twice and save the output of the second run to /root/ansplug/out/marker_run2.txt.
Unlike filters, tests, and lookups, a module is copied to the target and executed there. So it cannot read files on the controller, but it can change state — and being able to change it means it must take responsibility for idempotency and check mode itself. The skeleton of a module takes arguments with AnsibleModule(argument_spec=..., supports_check_mode=True) and ends with module.exit_json(changed=...). If you forget supports_check_mode, that task is skipped entirely in --check — it is silence rather than failure, which makes it more dangerous. Modules are found in library/ next to the playbook.
Connect all four kinds in one playbook
Use /root/ansplug/report.yml to write the four hosts in ascending name order, one line each, to /root/ansplug/out/report.txt — each line is <호스트> <이름슬러그> <포트> <포트분류> <reserved|free> <지역> (host, name slug, port, port class, reserved or free, and region), where the name slug is svc_name passed through slugify, the port class is port_class, the fifth column is the reserved_port test, and the region is the value obtained by asking labregistry for that host's first group name. Run the playbook twice and save the output of the second run to /root/ansplug/out/report_run2.txt.
The names of the groups a host belongs to are in hostvars[h].group_names, and here its first element is the registry key. If you put a Jinja2 {% for %} block directly into the content of the copy module, you can produce multiple lines at once. With the same input the file content is the same, so the second run must be changed=0 — this is the evidence that a "report-generating playbook" is idempotent.