Custom Modules - Ansible: Up and Running (2015)

Ansible: Up and Running (2015)

Chapter 10. Custom Modules

Sometimes you want to perform a task that is too complex for the command or shell modules, and there is no existing module that does what you want. In that case, you might want to write your own module.

In the past, I’ve written custom modules to retrieve my public IP address when I’m behind a network address translation (NAT) getaway, and to initialize the databases in an OpenStack deployment. I’ve thought about writing a custom module for generating self-signed TLS certificates, though I’ve never gotten around to it.

Another common use for custom modules is if you want to interact with some third-party service over a REST API. For example, GitHub offers what it calls Releases, which let you attach binary assets to repositories, and these are exposed via GitHub’s API. If your deployment required you to download a binary asset attached to a private GitHub repository, this would be a good candidate for implementing inside of a custom module.

Example: Checking That We Can Reach a Remote Server

Let’s say we want to check that we can connect to a remote server on a particular port. If we can’t, we want Ansible to treat that as an error and stop running the play.

NOTE

The custom module we will develop in this chapter is basically a simpler version of the wait_for module.

Using the Script Module Instead of Writing Your Own

Recall in Example 6-16 how we used the script module to execute custom scripts on remote hosts. Sometimes it’s simpler to just use the script module rather than write a full-blown Ansible module.

I like putting these types of scripts in a scripts folder along with my playbooks. For example, we could create a script file called playbooks/scripts/can_reach.sh that accepts as arguments the name of a host, the port to connect to, and how long it should try to connect before timing out.

can_reach.sh www.example.com 80 1

We can create a script as shown in Example 10-1.

Example 10-1. can_reach.sh

#!/bin/bash

host=$1

port=$2

timeout=$3

nc -z -w $timeout $host $port

We can then invoke this by doing:

- name: run my custom script

script: scripts/can_reach.sh www.example.com 80 1

Keep in mind that your script will execute on the remote hosts, just like Ansible modules do. Therefore, any programs your script requires must have been installed previously on the remote hosts. For example, you can write your script in Ruby, as long as Ruby has been installed on the remote hosts, and the first line of the script invokes the Ruby interpreter, such as:

#!/usr/bin/ruby

can_reach as a Module

Next, let’s implement can_reach as a proper Ansible module, which we will be able to invoke like this:

- name: check if host can reach the database server

can_reach: host=db.example.com port=5432 timeout=1

This will check if the host can make a TCP connection to db.example.com on port 5432. It will time out after one second if it fails to make a connection.

We’ll use this example throughout the rest of this chapter.

Where to Put Custom Modules

Ansible will look in the library directory relative to the playbook. In our example, we put our playbooks in the playbooks directory, so we will put our custom module at playbooks/library/can_reach.

How Ansible Invokes Modules

Before we actually implement the module, let’s go over how Ansible invokes them. Ansible will:

1. Generate a standalone Python script with the arguments (Python modules only).

2. Copy the module to the host.

3. Create an arguments file on the host (nonPython modules only).

4. Invoke the module on the host, passing the arguments file as an argument.

5. Parse the standard output of the module.

Let’s look at each of these steps in more detail.

Generate a Standalone Python Script with the Arguments (Python Only)

If the module is written in Python and uses the helper code that Ansible provides (described later), then Ansible will generate a self-contained Python script that injects helper code, as well as the module arguments.

Copy the Module to the Host

Ansible will copy the generated Python script (for Python-based modules) or the local file playbooks/library/can_reach (for non-Python-based modules) to a temporary directory on the remote host. If you are accessing the remote host as the ubuntu user, Ansible will copy the file to a path that looks like the following:

/home/ubuntu/.ansible/tmp/ansible-tmp-1412459504.14-47728545618200/can_reach

Create an Arguments File on the Host (Non-Python Only)

If the module is not written in Python, Ansible will create a file on the remote host with a name like this:

/home/ubuntu/.ansible/tmp/ansible-tmp-1412459504.14-47728545618200/arguments

If we invoke the module like this:

- name: check if host can reach the database server

can_reach: host=db.example.com port=5432 timeout=1

Then the arguments file will have the following contents:

host=db.example.com port=5432 timeout=1

We can tell Ansible to generate the arguments file for the module as JSON, by adding the following line to playbooks/library/can_reach:

# WANT_JSON

If our module is configured for JSON input, the arguments file will look like this:

{"host": "www.example.com", "port": "80", "timeout": "1"}

Invoke the Module

Ansible will call the module and pass the argument file as arguments. If it’s a Python-based module, Ansible executes the equivalent of the following (with /path/to/ replaced by the actual path):

/path/to/can_reach

If it’s a non-Python-based module, Ansible will look at the first line of the module to determine the interpreter and execute the equivalent of:

/path/to/interpreter /path/to/can_reach /path/to/arguments

Assuming the can_reach module is implemented as a Bash script and starts with:

#!/bin/bash

Then Ansible will do something like:

/bin/bash /path/to/can_reach /path/to/arguments

But even this isn’t strictly true. What Ansible actually does is:

/bin/sh -c 'LANG=en_US.UTF-8 LC_CTYPE=en_US.UTF-8 /bin/bash /path/to/can_reach \

/path/to/arguments; rm -rf /path/to/ >/dev/null 2>&1'

You can see the exact command that Ansible invokes by passing -vvv to ansible-playbook.

Expected Outputs

Ansible expects modules to output JSON. For example:

{'changed': false, 'failed': true, 'msg': 'could not reach the host'}

NOTE

Prior to version 1.8, Ansible supported a shorthand output format, also known as baby JSON, that looked like key=value. Ansible dropped support for this format in 1.8. As we’ll see later, if you write your modules in Python, Ansible provides some helper methods that make it easy to generate JSON output.

Output Variables Ansible Expects

Your module can return whatever variables you like, but Ansible has special treatment for certain returned variables:

changed

All Ansible modules should return a changed variable. The changed variable is a Boolean that indicates whether the module execution caused the host to change state. When Ansible runs, it will show in the output whether a state change has happened. If a task has a notify clause to notify a handler, the notification will fire only if changed is true.

failed

If the module failed to complete, it should return failed=true. Ansible will treat this task execution as a failure and will not run any further tasks against the host that failed, unless the task has an ignore_errors or failed_when clause.

If the module succeeds, you can either return failed=false or you can simply leave out the variable.

msg

Use the msg variable to add a descriptive message that describes the reason why a module failed.

If a task fails, and the module returns a msg variable, then Ansible will output that variable slightly differently than it does the other variables. For example, if a module returns:

{"failed": true, "msg": "could not reach www.example.com:81"}

Then Ansible will output the following lines when executing this task:

failed: [vagrant1] => {"failed": true}

msg: could not reach www.example.com:81

Implementing Modules in Python

If you implement your custom module in Python, Ansible provides the AnsibleModule Python class that makes it easier to:

§ Parse the inputs

§ Return outputs in JSON format

§ Invoke external programs

In fact, when writing a Python module, Ansible will inject the arguments directly into the generated Python file rather than require you to parse a separate arguments file. We’ll discuss how that works later in this chapter.

We’ll create our module in Python by creating a can_reach file. I’ll start with the implementation and then break it down (see Example 10-2).

Example 10-2. can_reach

#!/usr/bin/python

def can_reach(module, host, port, timeout):

nc_path = module.get_bin_path('nc', required=True) 1

args = [nc_path, "-z", "-w", str(timeout),

host, str(port)]

(rc, stdout, stderr) = module.run_command(args) 2

return rc == 0

def main():

module = AnsibleModule( 3

argument_spec=dict( 4

host=dict(required=True), 5

port=dict(required=True, type='int'),

timeout=dict(required=False, type='int', default=3) 6

),

supports_check_mode=True 7

)

# In check mode, we take no action

# Since this module never changes system state, we just

# return changed=False

if module.check_mode: 8

module.exit_json(changed=False) 9

host = module.params['host'] 10

port = module.params['port']

timeout = module.params['timeout']

if can_reach(module, host, port, timeout):

module.exit_json(changed=False)

else:

msg = "Could not reach %s:%s" % (host, port)

module.fail_json(msg=msg) 11

from ansible.module_utils.basic import * 12

main()

1

Gets the path of an external program

2

Invokes an external program

3

Instantiates the AnsibleModule helper class

4

Specifies the permitted set of arguments

5

A required argument

6

An optional argument with a default value

7

Specify that this module supports check mode

8

Test to see if module is running in check mode

9

Exit successfully, passing a return value

10

Extract an argument

11

Exit with failure, passing an error message

12

“Imports” the AnsibleModule helper class

Parsing Arguments

It’s easier to understand the way AnsibleModule handles argument parsing by looking at an example. Recall that our module is invoked like this:

- name: check if host can reach the database server

can_reach: host=db.example.com port=5432 timeout=1

Let’s assume that the host and port parameters are required, and timeout is an optional parameter with a default value of 3 seconds.

You instantiate an AnsibleModule object by passing it an argument_spec, which is a dictionary where the keys are parameter names and the values are dictionaries that contain information about the parameters.

module = AnsibleModule(

argument_spec=dict(

...

In our example, we declare a required argument named host. Ansible will report an error if this argument isn’t passed to the module when we use it in a task.

host=dict(required=True),

The variable named timeout is optional. Ansible assumes that arguments are strings unless specified otherwise. Our timeout variable is an integer, so we specify the type as int so that Ansible will automatically convert it into a Python number. If timeout is not specified, then the module will assume it has a value of 3:

timeout=dict(required=False, type='int', default=3)

The AnsibleModule constructor takes arguments other than argument_spec. In the preceding example, we added this argument:

supports_check_mode = True

This indicates that our module supports check mode. We’ll explain that a little later in this chapter.

Accessing Parameters

Once you’ve declared an AnsibleModule object, you can access the values of the arguments through the params dictionary, like this:

module = AnsibleModule(...)

host = module.params["host"]

port = module.params["port"]

timeout = module.params["timeout"]

Importing the AnsibleModule Helper Class

Near the bottom of the module, you’ll see this import statement:

from ansible.module_utils.basic import *

If you’ve written Python scripts before, you’re probably used to seeing an import at the top, rather than the bottom. However, this is really a pseudo import statement. It looks like a traditional Python import, but behaves differently.

Import statements behave differently in modules because Ansible copies only a single Python file to the remote host to execute it. Ansible simulates the behavior of a traditional Python import by including the imported code directly into the generated Python file (similar to how an#include statement works in C or C++).

Because Ansible will replace the import statement with code, the line numbers in the module as written will be different than the line numbers of the generated Python file. By putting the import statement at the bottom of the file, all of the line numbers above it are the same in the module and the generated file, which makes life much easier when interpreting Python tracebacks that contain line numbers.

Because this import statement behaves differently from a traditional Python import, you shouldn’t import classes explicitly, as shown in Example 10-3, even though explicit imports traditionally are considered good form in Python:

Example 10-3. Explicit imports (don’t do this)

from ansible.module_utils.basic import AnsibleModule

If you import explicitly, you won’t be able to use the Ansible module debugging scripts. That’s because these debugging scripts look for the specific string that includes the * and will fail with an error if they don’t find it.

NOTE

Earlier versions of Ansible used this line instead of an import statement to mark the location of where Ansible should insert the generated helper code.

#<<INCLUDE_ANSIBLE_MODULE_COMMON>>

Argument Options

For each argument to an Ansible module, you can specify several options:

Option

Description

required

If True, argument is required

default

Default value if argument is not required

choices

A list of possible values for the argument

aliases

Other names you can use as an alias for this argument

type

Argument type. Allowed values: 'str', 'list', 'dict', 'bool', 'int', 'float'

Table 10-1. Argument options

required

The required option is the only option that you should always specify. If it is true, then Ansible will return an error if the user failed to specify the argument.

In our can_reach module example, host and port are required, and timeout is not required.

default

For arguments that have required=False set, you should generally specify a default value for that option. In our example:

timeout=dict(required=False, type='int', default=3)

If the user invokes the module like this:

can_reach: host=www.example.com port=443

Then module.params["timeout"] will contain the value 3.

choices

The choices option allows you to restrict the allowed arguments to a predefined list.

Consider the distros argument in the following example:

distro=dict(required=True, choices=['ubuntu', 'centos', 'fedora'])

If the user were to pass an argument that was not in the list, for example:

distro=suse

This would cause Ansible to throw an error.

aliases

The aliases option allows you to use different names to refer to the same argument. For example, consider the package argument in the apt module:

module = AnsibleModule(

argument_spec=dict(

...

package = dict(default=None, aliases=['pkg', 'name'], type='list'),

)

)

Since pkg and name are aliases for the package argument, these invocations are all equivalent:

- apt: package=vim

- apt: name=vim

- apt: pkg=vim

type

The type option enables you to specify the type of an argument. By default, Ansible assumes all arguments are strings.

However, you can specify a type for the argument, and Ansible will convert the argument to the desired type. The types supported are:

§ str

§ list

§ dict

§ bool

§ int

§ float

In our example, we specified the port argument as an int:

port=dict(required=True, type='int'),

When we access it from the params dictionary, like this:

port = module.params['port']

Then the value of the port variable will be an integer. If we had not specified the type as int when declaring the port variable, then the module.params['port'] value would have been a string instead of an int.

Lists are comma-delimited. For example, if you had a module named foo with a list parameter named colors:

colors=dict(required=True, type='list')

Then you’d pass a list like this:

foo: colors=red,green,blue

For dictionaries, you can either do key=value pairs, delimited by commas, or you can do JSON inline.

For example, if you had a module named bar, with a dict parameter named tags:

tags=dict(required=False, type='dict', default={})

Then you could pass this argument like this:

- bar: tags=env=staging,function=web

Or you could pass the argument like this:

- bar: tags={"env": "staging", "function": "web"}

The official Ansible documentation uses the term complex args to refer to lists and dictionaries that are passed to modules as arguments. See “Complex Arguments in Tasks: A Brief Digression” for how to pass these types of arguments in playbooks.

AnsibleModule Initializer Parameters

The AnsibleModule initializer method takes a number of arguments. The only required argument is argument_spec.

Parameter

Default

Description

argument_spec

(none)

Dictionary that contains information about arguments

bypass_checks

False

If true, don’t check any of the parameter constrains

no_log

False

If true, don’t log the behavior of this module

check_invalid_arguments

True

If true, return error if user passed an unknown argument

mutually_exclusive

None

List of mutually exclusive arguments

required_together

None

List of arguments that must appear together

required_one_of

None

List of arguments where at least one must be present

add_file_common_args

False

Supports the arguments of the file module

supports_check_mode

False

If true, indicates module supports check mode

Table 10-2. AnsibleModule initializer arguments

argument_spec

This is a dictionary that contains the descriptions of the allowed arguments for the module, as described in the previous section.

no_log

When Ansible executes a module on a host, the module will log output to the syslog, which on Ubuntu is at /var/log/syslog.

The logging output looks like this:

Sep 28 02:31:47 vagrant-ubuntu-trusty-64 ansible-ping: Invoked with data=None

Sep 28 02:32:18 vagrant-ubuntu-trusty-64 ansible-apt: Invoked with dpkg_options=

force-confdef,force-confold upgrade=None force=False name=nginx package=['nginx'

] purge=False state=installed update_cache=True default_release=None install_rec

ommends=True deb=None cache_valid_time=None Sep 28 02:33:01 vagrant-ubuntu-trust

y-64 ansible-file: Invoked with src=None

original_basename=None directory_mode=None force=False remote_src=None selevel=N

one seuser=None recurse=False serole=None content=None delimiter=None state=dire

ctory diff_peek=None mode=None regexp=None owner=None group=None path=/etc/nginx

/ssl backup=None validate=None setype=None

Sep 28 02:33:01 vagrant-ubuntu-trusty-64 ansible-copy: Invoked with src=/home/va

grant/.ansible/tmp/ansible-tmp-1411871581.19-43362494744716/source directory_mod

e=None force=True remote_src=None dest=/etc/nginx/ssl/nginx.key selevel=None seu

ser=None serole=None group=None content=NOT_LOGGING_PARAMETER setype=None origin

al_basename=nginx.key delimiter=None mode=0600 owner=root regexp=None validate=N

one backup=False

Sep 28 02:33:01 vagrant-ubuntu-trusty-64 ansible-copy: Invoked with src=/home/va

grant/.ansible/tmp/ansible-tmp-1411871581.31-95111161791436/source directory_mod

e=None force=True remote_src=None dest=/etc/nginx/ssl/nginx.crt selevel=None seu

ser=None serole=None group=None content=NOT_LOGGING_PARAMETER setype=None origin

al_basename=nginx.crt delimiter=None mode=None owner=None regexp=None validate=N

one backup=False

If a module accepts sensitive information as an argument, you might want to disable this logging.

To configure a module so that it does not write to syslog, pass the no_log=True parameter to the AnsibleModule initializer.

check_invalid_arguments

By default, Ansible will verify that all of the arguments that a user passed to a module are legal arguments. You can disable this check by passing the check_invalid_arguments=False parameter to the AnsibleModule initializer.

mutually_exclusive

The mutually_exclusive parameter is a list of arguments that cannot be specified during the same module invocation.

For example, the lineinfile module allows you to add a line to a file. You can use the insertbefore argument to specify which line it should appear before, or the insertafter argument to specify which line it should appear after, but you can’t specify both.

Therefore, this module specifies that the two arguments are mutually exclusive, like this:

mutually_exclusive=[['insertbefore', 'insertafter']]

required_one_of

The required_one_of parameter is a list of arguments where at least one must be passed to the module.

For example, the pip module, which is used for installing Python packages, can take either the name of a package or the name of a requirements file that contains a list of packages. The module specifies that one of these arguments is required like this:

required_one_of=[['name', 'requirements']]

add_file_common_args

Many modules create or modify a file. A user will often want to set some attributes on the resulting file, such as the owner, group, and file permissions.

You could invoke the file module to set these parameters, like this:

- name: download a file

get_url: url=http://www.example.com/myfile.dat dest=/tmp/myfile.dat

- name: set the permissions

file: path=/tmp/myfile.dat owner=ubuntu mode=0600

As a shortcut, Ansible allows you to specify that a module will accept all of the same arguments as the file module, so you can simply set the file attributes by passing the relevant arguments to the module that created or modified the file. For example:

- name: download a file

get_url: url=http://www.example.com/myfile.dat dest=/tmp/myfile.dat \

owner=ubuntu mode=0600

To specify that a module should support these arguments:

add_file_common_args=True

The AnsibleModule module provides helper methods for working with these arguments.

The load_file_common_arguments method takes the parameters dictionary as an argument and returns a parameters dictionary that contains all of the arguments that relate to setting file attributes.

The set_fs_attributes_if_different method takes a file parameters dictionary and a Boolean indicating whether a host state change has occurred yet. The method sets the file attributes as a side effect and returns true if there was a host state change (either the initial argument was true, or it made a change to the file as part of the side effect).

If you are using the file common arguments, do not specify the arguments explicitly. To get access to these attributes in your code, use the helper methods to extract the arguments and set the file attributes, like this:

module = AnsibleModule(

argument_spec=dict(

dest=dict(required=True),

...

),

add_file_common_args=True

)

# "changed" is True if module caused host to change state

changed = do_module_stuff(param)

file_args = module.load_file_common_arguments(module.params)

changed = module.set_fs_attributes_if_different(file_args, changed)

module.exit_json(changed=changed, ...)

NOTE

Ansible assumes your module has an argument named path or dest, which contains the path to the file.

bypass_checks

Before an Ansible module executes, it first checks that all of the argument constraints are satisfied, and returns an error if they aren’t. These include:

§ No mutually exclusive arguments are present.

§ Arguments marked with the required option are present.

§ Arguments restricted by the choices option have the expected values.

§ Arguments where a type is specified have values that are consistent with the type.

§ Arguments marked as required_together appear together.

§ At least one argument in the list of required_one_of is present.

You can disable all of these checks by setting bypass_checks=True.

Returning Success or Failure

Use the exit_json method to return success. You should always return changed as an argument, and it’s good practice to return msg with a meaningful message:

module = AnsibleModule(...)

...

module.exit_json(changed=False, msg="meaningful message goes here")

Use the fail_json method to indicate failure. You should always return a msg parameter to explain to the user the reason for the failure:

module = AnsibleModule(...)

...

module.fail_json(msg="Out of disk space")

Invoking External Commands

The AnsibleModule class provides the run_command convenience method for calling an external program, which wraps the native Python subprocess module. It accepts the following arguments.

Argument

Type

Default

Description

args (default)

string or list of strings

(none)

The command to be executed (see the following section)

check_rc

Boolean

False

If true, will call fail_json if command returns a non-zero value.

close_fds

Boolean

True

Passes as close_fds argument to subprocess.Popen

executable

string (path to program)

None

Passes as executable argument to subprocess.Popen

data

string

None

Send to stdin if child process

binary_data

Boolean

False

If false and data is present, Ansible will send a newline to stdin after sending data

path_prefix

string (list of paths)

None

Colon-delimited list of paths to prepend to PATH environment variable

cwd

string (directory path)

None

If specified, Ansible will change to this directory before executing

use_unsafe_shell

Boolean

False

See the following section

Table 10-3. run_command arguments

If args is passed as a list, as shown in Example 10-4, then Ansible will invoke subprocess.Popen with shell=False.

Example 10-4. Passing args as a list

module = AnsibleModule(...)

...

module.run_command(['/usr/local/bin/myprog', '-i', 'myarg'])

If args is passed as a string, as shown in Example 10-5, then the behavior depends on the value of use_unsafe_shell. If use_unsafe_shell is false, Ansible will split args into a list and invoke subprocess.Popen with shell=False. If use_unsafe_shell is true, Ansible will pass args as a string to subprocess.Popen with shell=True.1

Example 10-5. Passing args as a string

module = AnsibleModule(...)

...

module.run_command('/usr/local/bin/myprog -i myarg')

Check Mode (Dry Run)

Ansible supports something called “check mode,” which is enabled when passing the -C or --check flag to ansible-playbook. It is similar to the “dry run” mode supported by many other tools.

When Ansible runs a playbook in check mode, it will not make any changes to the hosts when it runs. Instead, it will simply report whether each task would have changed the host, returned successfully without making a change, or returned an error.

Support Check Mode

Modules must be explicitly configured to support check mode. If you’re going to write your own module, I recommend you support check mode so that your module is a good Ansible citizen.

To tell Ansible that your module supports check mode, set supports_check_mode to true in the AnsibleModule initializer method, as shown in Example 10-6.

Example 10-6. Telling Ansible the module supports check mode

module = AnsibleModule(

argument_spec=dict(...),

supports_check_mode=True)

Your module should check that check mode has been enabled by checking the value of the `check_mode`2 attribute of the AnsibleModule object, as shown in Example 10-7. Call the exit_json or fail_json methods as you would normally.

Example 10-7. Checking if check mode is enabled

module = AnsibleModule(...)

...

if module.check_mode:

# check if this module would make any changes

would_change = would_executing_this_module_change_something()

module.exit_json(changed=would_change)

It is up to you, the module author, to ensure that your module does not modify the state of the host when running in check mode.

Documenting Your Module

You should document your modules according to the Ansible project standards so that HTML documentation for your module will be correctly generated and the ansible-doc program will display documentation for your module. Ansible uses a special YAML-based syntax for documenting modules.

Near the top of your module, define a string variable called DOCUMENTATION that contains the documentation, and a string variable called EXAMPLES that contains example usage.

Example 10-8 shows an example for the documentation section for our can_reach module.

Example 10-8. Example of module documentation

DOCUMENTATION = '''

---

module: can_reach

short_description: Checks server reachability

description:

- Checks if a remote server can be reached

version_added: "1.8"

options:

host:

description:

- A DNS hostname or IP address

required: true

port:

description:

- The TCP port number

required: true

timeout:

description:

- The amount of time try to connecting before giving up, in seconds

required: false

default: 3

flavor:

description:

- This is a made-up option to show how to specify choices.

required: false

choices: ["chocolate", "vanilla", "strawberry"]

aliases: ["flavour"]

default: chocolate

requirements: [netcat]

author: Lorin Hochstein

notes:

- This is just an example to demonstrate how to write a module.

- You probably want to use the native M(wait_for) module instead.

'''

EXAMPLES = '''

# Check that ssh is running, with the default timeout

- can_reach: host=myhost.example.com port=22

# Check if postgres is running, with a timeout

- can_reach: host=db.example.com port=5432 timeout=1

'''

Ansible supports some limited markup in the documentation. Table 10-4 shows the markup syntax supported by the Ansible documentation tool with recommendations about when you should use this markup:

Type

Syntax with example

When to use

URL

U(http://www.example.com)

URLs

Module

M(apt)

Module names

Italics

I(port)

Parameter names

Constant-width

C(/bin/bash)

File and option names

Table 10-4. Documentation markup

The existing Ansible modules are a great source of examples for documentation.

Debugging Your Module

The Ansible repository in GitHub contains a couple of scripts that allow you to invoke your module directly on your local machine, without having to run it using the ansible or ansible-playbook commands.

Clone the Ansible repo:

$ git clone https://github.com/ansible/ansible.git --recursive

Set up your environment variables so that you can invoke the module:

$ source ansible/hacking/env-setup

Invoke your module:

$ ansible/hacking/test-module -m /path/to/can_reach -a "host=example.com port=81"

Since example.com doesn’t have a service that listens on port 81, our module should fail with a meaningful error message. And it does:

* including generated source, if any, saving to:

/Users/lorinhochstein/.ansible_module_generated

* this may offset any line numbers in tracebacks/debuggers!

***********************************

RAW OUTPUT

{"msg": "Could not reach example.com:81", "failed": true}

***********************************

PARSED OUTPUT

{

"failed": true,

"msg": "Could not reach example.com:81"

}

As the output suggests, when you run this test-module, Ansible will generate a Python script and copy it to ~/.ansible_module_generated. This is a standalone script that you can execute directly if you like. The debug script will replace the following line:

from ansible.module_utils.basic import *

with the contents of the file lib/ansible/module_utils/basic.py, which can be found in the Ansible repository.

This file does not take any arguments; rather, Ansible inserts the arguments directly into the file:

MODULE_ARGS = 'host=example.com port=91'

Implementing the Module in Bash

If you’re going to write an Ansible module, I recommend writing it in Python because, as we saw earlier in this chapter, Ansible provides helper classes for writing your modules in Python. However, you can write modules in other languages as well. Perhaps you need to write in another language because your module depends on a third-party library that’s not implemented in Python. Or maybe the module is so simple that it’s easiest to write it in Bash. Or, maybe, you just prefer writing your scripts in Ruby.

In this section, we’ll work through an example of implementing the module as a Bash script. It’s going to look quite similar to the implementation in Example 10-1. The main difference is parsing the input arguments and generating the outputs that Ansible expects.

I’m going to use the JSON format for input and use a tool called jq for parsing out JSON on the command line. This means that you’ll need to install jq on the host before invoking this module. Example 10-9 shows the complete Bash implementation of our module.

Example 10-9. can_reach module in Bash

#!/bin/bash

# WANT_JSON

# Read the variables form the file

host=`jq -r .host < $1`

port=`jq -r .port < $1`

timeout=`jq -r .timeout < $1`

# Check if we can reach the host

nc -z -w $timeout $host $port

# Output based on success or failure

if [ $? -eq 0 ]; then

echo '{"changed": false}'

else

echo "{\"failed\": true, \"msg\": \"could not reach $host:$port\"}"

fi

We added WANT_JSON in a comment to tell Ansible that we want the input to be in JSON syntax.

BASH MODULES WITH SHORTHAND INPUT

It’s possible to implement Bash modules using the shorthand notation for input. I don’t recommend doing it this way, since the simplest approach involves using the source built-in, which is a potential security risk. However, if you’re really determined, check out the blog post, “Shell scripts as Ansible modules,” by Jan-Piet Mens.

Specifying an Alternaive Location for Bash

Note that our module assumes that Bash is located at /bin/bash. However, not all systems will have the Bash executable in that location. You can tell Ansible to look elsewhere for the Bash interpreter by setting the ansible_bash_interpreter variable on hosts that install it elsewhere.

For example, let’s say you have a FreeBSD host named fileserver.example.com that has Bash installed in /usr/local/bin/bash. You can create a host variable by creating the file host_vars/fileserver.example.com that contains:

ansible_bash_interpreter: /usr/local/bin/bash

Then, when Ansible invokes this module on the FreeBSD host, it will use /usr/local/bin/bash instead of /bin/bash.

Ansible determines which interpreter to use by looking for the “she-bang” (#!) and then looking at the basename of the first element. In our example, Ansible would see this line:

#!/bin/bash

Ansible would then look for the basename of /bin/bash, which is bash. It would then use the ansible_bash_interpreter if the user specified one.

WARNING

Because of how Ansible looks for the interpreter, if your she-bang calls /usr/bin/env, for example:

#!/usr/bin/env bash

Ansible will mistakenly identify the interpreter as env because it will call basename on /usr/bin/env to identify the interpreter.

The takeaway is: don’t invoke env in she-bang. Instead, explicitly specify the location of the interpreter and override with ansible_bash_interpreter (or equivalent) when needed.

Example Modules

The best way to learn how to write Ansible modules is to read the source code for the modules that ship with Ansible. Check them out on GitHub: modules core and modules extras.

In this chapter, we covered how to write modules in Python, as well as other languages, and how to avoid writing your own full-blown modules using the script module. If you do write a module, I encourage you to propose it for inclusion in the main Ansible project.

1 For more on the Python standard library subprocess.Popen class, see its online documentation.

2 Phew! That was a lot of checks.