Practical Ports: Developing Custom Ansible Modules

Practical Ports: Developing Custom Ansible Modules

By Benedict Reuschling

Ansible offers a lot of different modules and a typical user makes use of them without the need to ever write their own due to the sheer size of available modules. Even if the necessary functionality is not available in the ansible.builtin modules, the Ansible Galaxy offers plenty of third party modules from enthusiasts that extend the module count even more.

When the desired functionality is not covered by a single module or a combination of them, then you have to develop your own. Developers can chose to keep custom models local without having to publish them on the Internet or without Ansible Galaxy using them. Modules are commonly developed in Python, but other programming languages are possible when the module is not planned for submission into the official Ansible ecosystem.

To test the module, install the ansible-core package, which helps by providing common code that Ansible uses internally. The custom module can then piggy-back onto much of the core Ansible functionality that existing modules use and is both reliable and stable.

Example Module Using Shell Programming

We’ll start with a simple example to understand the basics. Later, we will extend it to use Python for more functionality.

Description of our custom module: Our custom module called touch checks for a file in /tmp called BSD.txt. If it exists, the module returns true (state unchanged). If it does not exist, it creates that (empty) file and returns state: changed.

Custom modules are in a library directory next to the playbook that uses the module. Create that directory using mkdir:

mkdir library

Create a shell script in library that holds the module code:

touch library/touch

Enter the following code in library/touch as the module logic:

&nbs;1&nbs;&nbs;FILENAME=/tmp/BSD.txt
&nbs;2&nbs;&nbs;changed=false
&nbs;3&nbs;&nbs;msg=''
&nbs;4&nbs;&nbs;if [ ! -f ${FILENAME} ]; then
&nbs;5&nbs;&nbs;&nbs;&nbs;&nbs;&nbs;touch ${FILENAME}
&nbs;6&nbs;&nbs;&nbs;&nbs;&nbs;&nbs;msg=”${FILENAME} created”
&nbs;7&nbs;&nbs;&nbs;&nbs;&nbs;&nbs;changed=true
&nbs;8&nbs;&nbs;fi
&nbs;9&nbs;&nbs;printf ‘{“changed”: “%s”, “msg”: “%s”}’ “$changed” “$msg”

First, we define some variables and set some default values. Line 4 checks if the file does not exist. If that is the case, we let the module create the file and update the msg variable. We need to notify Ansible about the changed state, so we return a variable called changed along with the updated message in line.

Create a playbook called touch.yml at the same location as the library directory. It looks like this:

---
- hosts: localhost
&nbs;&nbs;gather_facts: false
&nbs;&nbs;tasks:
&nbs;&nbs;&nbs;&nbs;- name: Run our custom touch module
&nbs;&nbs;&nbs;&nbs;&nbs;&nbs;touch:
&nbs;&nbs;&nbs;&nbs;&nbs;&nbs;register: result

&nbs;&nbs;&nbs;&nbs;- debug: var=result

Note: We could execute the custom module against any remote nodes, not localhost alone. It’s easier to test against localhost first during development.

Run the playbook like any other we’ve written before:

ansible-playbook touch.yml

Running the Example Module

When the file /tmp/BSD.txt does not exist, the playbook output is:

PLAY [localhost] *****************************************

TASK [Run our custom touch module] ***********************
changed: [localhost]

TASK [debug] *********************************************
ok: [localhost] => {
&nbs;&nbs;&nbs;&nbs;“changed”: true,
&nbs;&nbs;&nbs;&nbs;“result”: {
&nbs;&nbs;&nbs;&nbs;&nbs;&nbs;&nbs;&nbs;“failed”: false,
&nbs;&nbs;&nbs;&nbs;&nbs;&nbs;&nbs;&nbs;“msg”: “/tmp/BSD.txt created”
&nbs;&nbs;&nbs;&nbs;}
}

When the file /tmp/BSD.txt exists (from a previous run), the output is:

PLAY [localhost] *****************************************

TASK [Run our custom touch module] ***********************
ok: [localhost]

TASK [debug] *********************************************
ok: [localhost] => {
“result”: {
        “changed”: false,
        “failed”: false,
        “msg”: “”
    }
}

Custom Modules in Python

What are the benefits of writing a module in Python, like the rest of the ansible.builtin modules? One benefit is that we can use the existing parsing library for the module parameters without having to reinvent our own. It’s difficult in shell to define the name of each parameter in our own module. In Python, we can teach the module to accept some parameters as optional and require others as mandatory. Data types define what kind of inputs the module user must provide for each parameter. For example, a dest: parameter should be a path data type rather than an integer. Ansible provides some handy functionality to include in our script so that we can focus on our module’s core functionality.

The Ansiballz Framework

Modern Ansible modules use the Ansiballz framework. Unlike the Module Replacer, which were used by Ansible versions before 2.1, it uses real Python imports from ansible/module_utils instead of preprocessing the module.

Module Functionality: Ansiballz constructs a zip file. Contents:

  • the module file
  • ansible/module_utils files imported by the module
  • boilerplate for the module parameters

The zip file is Base64 encoded and wrapped into a small Python script for decoding it. Next, Ansible copies it into the temp directory of the target node. When executing, the Ansible module script extracts the zip file and places itself in the temp dir, too. It then sets the PTHONPATH to find Python modules inside the zip and imports the Ansible module under the special name. Python then thinks it executes a regular script rather than importing a module. This allows Ansible to run both the wrapper script and the module’s code in a single Python copy on the target host.

Creating the Python Module

To create a module, use a venv or virtualenv for the development part. We start like before with a library directory where we create a new hello.py module with this content:

#!/usr/bin/env python3

from ansible.module_utils.basic import *

def main():
    module = AnsibleModule(argument_spec={})
    response = {“hello”: “world!”}
    module.exit_json(changed=False, meta=response)

if name == “__main__”:
    main()

import imports the Ansiballz framework to construct modules. It includes code constructs like argument parsing, file operations, and formatting return values as JSON.

Executing the Python Module from a Playbook

---
- hosts: localhost
  gather_facts: false
  tasks:
    - name: Testing the Python module
      hello:
      register: result

    - debug: var=result

Again, we run the playbook like this: ansible-playbook hello.yml

PLAY [localhost] *****************************************

TASK [Testing the Python module] *************************
ok: [localhost]

TASK [debug] *********************************************
ok: [localhost] => {
“result”: {
        “changed”: false,
        “failed”: false,
        “meta”: {
            “hello”: “world!”
        }
    }
}

Defining Module Parameters

The modules we used had taken parameters like path:, src:, or dest: to control the behavior of the module. Some of these parameters are essential for the module to function properly, while others were optional. In our own module, we want to control what parameters we take overall and which are required. Defining the data type makes our module robust against incorrect inputs.

The argument_spec provided to AnsibleModule defines the supported module arguments, as well as their type, defaults, and more.

Example parameter definition:

parameters = {
    'name': {“required”: True, “type”: 'str'},
'age': {“required”: False, “type”: 'int', “default”: 0},
    'homedir': {“required”: False, “type”: 'path'}
}

The required parameter name is of type string. Both age (an integer) and homedir (a path) are optional and if not defined, sets age to 0 by default. A new module that uses these parameter definitions calculates the result from passing two numbers and an optional math operator. When not provided, we assume an addition as default parameter. Create a new python file in library called calc.py:

#!/usr/bin/env python3
from ansible.module_utils.basic import AnsibleModule

def main():
    parameters = {
       “number1”: {“required”: True, “type”: “int”},
“number2”: {“required”: True, “type”: “int”},
        “math_op”: {“required”: False, “type”: “str”, “default”: “+”},
    }

    module = AnsibleModule(argument_spec=parameters)

    number1 = module.params[“number1”]
    number2 = module.params[“number2”]
    math_op = module.params[“math_op”]

    if math_op == “+”:
        result = number1 + number2

    output = {
        “result”: result,
    }

    module.exit_json(changed=False, **output)

if __name__ == “__main__”:
    main()

The Playbook for the Module

---
- hosts: localhost
  gather_facts: false
  tasks:
    - name: Testing the calc module
      calc:
        number1: 4
        number2: 3
      register: result

    - debug: var=result

The calc module optionally takes a parameter math_op, but since we defined a default action (+) for it, the user can omit it in the playbook or on the commandline. The task that runs the module must specify the required parameters or the playbook will fail to execute.

Running the calc Module

The relevant output of the playbook execution is below:

ok: [localhost] => {
    “result”: {
        “changed”: false,
        “failed”: false,
“result”: 7
    }
}

We extend the example to properly handle +, -, *, /. The module returns false when it gets a math_op that is is different from the ones defined. Also, handling division by zero by returning “Invalid Operation” is a classic assignment for students since the dawn of time. I need to properly learn Python one day, but until then, my solution looks like this:

#!/usr/bin/env python3
from ansible.module_utils.basic import AnsibleModule

def main():
    parameters = {
        “number1”: {“required”: True, “type”: “int”},
“number2”: {“required”: True, “type”: “int”},
        “operation”: {“required”: False, “type”: “str”, “default”: “+”},
}

    module = AnsibleModule(argument_spec=parameters)

number1 = module.params[“number1”]
    number2 = module.params[“number2”]
    operation = module.params[“operation”]
    result = “”

    if operation == “+”:
        result = number1 + number2
    elif operation == “-”:
        result = number1 - number2
    elif operation == “*”:
        result = number1 * number2
    elif operation == “/”:
        if number2 == 0:
            module.fail_json(msg=”Invalid Operation”)
        else:
            result = number1 / number2
    else:
        result = False

    output = {
        “result”: result,
    }

    module.exit_json(changed=False, **output)

if __name__ == “__main__”:
main()

Testing our extended module is straightforward. Here is the test for division by zero:

---
- hosts: localhost
  gather_facts: false
  tasks:
    - name: Testing the calc module
      calc:
        number1: 4
        number2: 0
        map_op: ‘/’
      register: result

    - debug: var=result

Which results in the following expected output:

TASK [Testing the calc module] **********************************************
fatal: [localhost]: FAILED! => {“changed”: false, “msg”: “Invalid Operation”}

Conclusion

With these basics, its easy to get started on a custom module. Bear in mind that these modules need to run on different operating systems. Add extra checks to find out the availability of certain commands or let your module outright refuse to run in certain environments. Be as compatible as possible to increase the module’s popularity and usefulness. There are not a lot of BSD-specific modules available. How about adding a bhyve module, or one that manages boot environments, the pf firewall or rc.conf entries? Plenty of options await the intrepid developer with a background in both Ansible and Python.

References:

BENEDICT REUSCHLING is a documentation committer in the FreeBSD project and member of the documentation engineering team. In the past, he served on the FreeBSD core team for two terms. He administers a big data cluster at the University of Applied Sciences, Darmstadt, Germany. He’s also teaching a course “Unix for Developers” for undergraduates. Benedict is one of the hosts of the weekly bsdnow.tv podcast.