Cloud AI Campus
  • Career paths
  • Learning paths
  • Hands-on Labs
Log in Sign up

🧪 Hands-on lab · 60 min

Ansible — Creating Modules & Plugins

  1. 1. A custom module in Python
  2. 2. A custom module in Bash
  3. 3. A filter plugin
  4. 4. A lookup plugin

A custom module in Python

A module is anything that prints a JSON object Ansible can interpret. The Python helper AnsibleModule handles argument parsing, check mode, and failure reporting for you.

Create library/cac_greet.py:

#!/usr/bin/python3
# NOTE: prefer an absolute path here rather than `/usr/bin/env python3`.
# Ansible reads the shebang to pick the remote interpreter, and a path
# with a space (anything after `env`) confuses its quoting.
from ansible.module_utils.basic import AnsibleModule

def main():
    module = AnsibleModule(
        argument_spec=dict(
            name=dict(type='str', required=True),
            shout=dict(type='bool', default=False),
        ),
        supports_check_mode=True,
    )
    name  = module.params['name']
    shout = module.params['shout']
    greeting = f"Hello, {name}!"
    if shout:
        greeting = greeting.upper()
    module.exit_json(changed=False, greeting=greeting)

if __name__ == '__main__':
    main()

Make it executable:

chmod +x library/cac_greet.py

Then greet.yml:

---
- name: Use the custom module
  hosts: centos1
  gather_facts: false
  tasks:
    - name: Polite greeting
      cac_greet:
        name: "{{ inventory_hostname }}"
      register: nice

    - name: Loud greeting
      cac_greet:
        name: "{{ inventory_hostname }}"
        shout: true
      register: loud

    - name: Show both
      debug:
        msg: "{{ nice.greeting }} | {{ loud.greeting }}"
...

Run it:

ansible-playbook greet.yml

You should see Hello, centos1! | HELLO, CENTOS1!. Ansible found the module by name because library/ next to the playbook is on the module search path by default.

Click Verify step.

Hint

Place under `library/<name>.py`; use `AnsibleModule()` from `ansible.module_utils.basic`.

A custom module in Bash

The module contract is language-agnostic. Anything that reads its single command-line argument (a path to a temp JSON file with the task's args) and prints JSON to stdout can be a module.

Create library/cac_filesize:

#!/bin/bash
# Cloud AI Campus — minimal bash module returning a file's size.
# The literal token WANT_JSON below tells ansible to pass arguments as
# a JSON file rather than the default space-separated key=value form.

# WANT_JSON

ARGS_FILE=$1
PATH_VAL=$(python3 -c 'import sys, json; print(json.load(open(sys.argv[1])).get("path", ""))' "$ARGS_FILE")

if [ -z "$PATH_VAL" ]; then
    printf '{"failed": true, "msg": "path argument required"}\n'
    exit 1
fi

if [ ! -e "$PATH_VAL" ]; then
    printf '{"failed": true, "msg": "%s does not exist"}\n' "$PATH_VAL"
    exit 1
fi

SIZE=$(stat -c %s "$PATH_VAL" 2>/dev/null || stat -f %z "$PATH_VAL")
printf '{"changed": false, "path": "%s", "size": %d}\n' "$PATH_VAL" "$SIZE"

The WANT_JSON marker is a literal string that ansible greps for in the module source. With it, ansible passes a JSON file. Without it, args come in as key=value space-separated pairs (the WANT_JSON-less form is harder to parse safely, so always opt in to JSON).

chmod +x library/cac_filesize

Then filesize.yml:

---
- name: Probe sizes with the bash module
  hosts: centos1
  gather_facts: false
  tasks:
    - name: Measure /etc/hosts
      cac_filesize:
        path: /etc/hosts
      register: sz

    - name: Show the result
      debug:
        msg: "{{ sz.path }} is {{ sz.size }} bytes"
...
ansible-playbook filesize.yml

The trick: Ansible ships the module to the target, runs it there, and parses whatever JSON it prints. Anything more elaborate (real arg parsing, types, check-mode) is exactly why the Python AnsibleModule helper exists.

Click Verify step.

Hint

Modules are anything that reads JSON args from $1 and prints JSON result to stdout.

A filter plugin

Filters are Jinja2 transforms — {{ value | filter_name(args) }}. Ansible ships hundreds (upper, default, regex_replace, etc.), but you can add your own.

Create filter_plugins/reverse_words.py:

# Filter plugin: reverse the WORDS in a string.
# Usage: "{{ 'hello world from ansible' | reverse_words }}"

def reverse_words(value):
    return ' '.join(reversed(str(value).split()))

class FilterModule:
    def filters(self):
        return {
            'reverse_words': reverse_words,
        }

Then filter.yml:

---
- name: Use the custom filter
  hosts: centos1
  gather_facts: false
  tasks:
    - name: Reverse word order
      debug:
        msg: "{{ 'cloud ai campus dive into ansible' | reverse_words }}"

    - name: Chain with built-in filters
      debug:
        msg: "{{ 'how now brown cow' | reverse_words | upper }}"
...
ansible-playbook filter.yml

Output:

ansible into dive campus ai cloud
COW BROWN NOW HOW

Filters can take arguments like any function: | my_filter(extra='arg').

Click Verify step.

Hint

`filter_plugins/<name>.py` exposes a class with a `filters()` method returning a dict of name→callable.

A lookup plugin

Lookups resolve external data at template time — useful for pulling secrets, generating values, or talking to APIs.

Create lookup_plugins/yelling_uuid.py:

# Lookup plugin: generate a SHOUTING uuid for each "term" passed in.
# Usage: "{{ lookup('yelling_uuid', 'tag-a', 'tag-b') }}"

import uuid
from ansible.plugins.lookup import LookupBase

class LookupModule(LookupBase):
    def run(self, terms, variables=None, **kwargs):
        out = []
        for term in terms:
            generated = str(uuid.uuid4()).upper()
            out.append(f"{term}-{generated}")
        return out

Then lookup.yml:

---
- name: Use the custom lookup
  hosts: centos1
  gather_facts: false
  tasks:
    - name: Mint two IDs
      debug:
        msg: "{{ lookup('yelling_uuid', 'session', 'student') }}"

    - name: Or assign into a variable
      set_fact:
        my_session_id: "{{ lookup('yelling_uuid', 'sess')[0] }}"

    - name: Use it
      debug:
        msg: "my_session_id={{ my_session_id }}"
...
ansible-playbook lookup.yml

lookup() always returns a list even for a single term — that's why we index with [0] when we want a scalar.

Click Verify step.

Hint

`lookup_plugins/<name>.py` defines a `LookupModule` subclass with `run(terms, variables, ...)`.

© 2026 Cloud AI Campus