By Brad Johnson, Lead DevOps Engineer
When developing automation you may be faced with challenges that are simply too complicated or tedious to accomplish with Ansible alone. There may even be cases where you are told that “it can’t be automated”. However, when you combine the abilities of Ansible and custom python using the pexpect module, then you are able to automate practically anything you can do on the command line. In this post we will discuss the basics of creating a custom Ansible module in python.
Here are a few examples of cases where you might need to create a custom module:
- Running command line programs that drop into a new shell or interactive interface.
- Processing complex data and returning a subset of specific data in a new format.
- Interacting with a database and returning data in a specific format for further Ansible processing.
For the purposes of this article we will focus on the first case. When writing a traditional linux shell or bash script it simply isn’t possibly to continue your script when a command you run drops you into a new shell or new interactive interface. If these tools also provided a non-interactive mode or config/script input we would not need to do this. To overcome this situation we need to use python with pexpect. The native Ansible “expect” module provides a simple interface to this functionality and should be evaluated before writing a custom module. However, when you need more complex interactions, want specific data returned or want to provide a re-usable and simpler interface to an underlying program for to others to consume, then custom development if warranted.
In this guide I will talk about the requirements and steps needed to create your own library module. The source code with our example is located here and contains notes in the code as well. The pexpect code is intentionally complex to demonstrate some use cases.
Module Code (Python)
#!/usr/bin/env python
import os
import getpass
DOCUMENTATION = '''
---
module: my_module
short_description: This is a custom module using pexpect to run commands in myscript.sh
description:
- "This module runs commands inside a script in a shell. When run without commands it returns current settings only."
options:
commands:
description:
- The commands to run inside myscript in order
required: false
options:
description:
- options to pass the script
required: false
timeout:
description:
- Timeout for finding the success string or running the program
required: false
default: 300
password:
description:
- Password needed to run myscript
required: true
author:
- Brad Johnson - Keyva
'''
EXAMPLES = '''
- name: "Run myscript to set up myprogram"
my_module:
options: "-o myoption"
password: "{{ myscript_password }}"
commands:
- "set minheap 1024m"
- "set maxheap 5120m"
- "set port 7000"
- "set webport 80"
timeout: 300
'''
RETURN = '''
current_settings: String containing current settings after last command was run and settings saved
type: str
returned: On success
logfile: String containing logfile location on the remote host from our script
type: str
returned: On success
'''
def main():
# This is the import required to make this code an Ansible module
from ansible.module_utils.basic import AnsibleModule
# This instantiates the module class and provides Ansible with
# input argument information, it also enforces input types
module = AnsibleModule(
argument_spec=dict(
commands=dict(required=False, type='list', default=[]),
options=dict(required=False, type='str', default=""),
password=dict(required=True, type='str', no_log=True),
timeout=dict(required=False, type='int', default='300')
)
)
commands = module.params['commands']
options = module.params['options']
password = module.params['password']
timeout = module.params['timeout']
try:
# Importing the modules here allows us to catch them not being installed on remote hosts
# and pass back a failure via ansible instead of a stack trace.
import pexpect
except ImportError:
module.fail_json(msg="You must have the pexpect python module installed to use this Ansible module.")
try:
# Run our pexpect function
current_settings, changed, logfile = run_pexpect(commands, options, password, timeout)
# Exit on success and pass back objects to ansible, which are available as registered vars
module.exit_json(changed=changed, current_settings=current_settings, logfile=logfile)
# Use python exception handling to keep all our failure handling in our main function
except pexpect.TIMEOUT as err:
module.fail_json(msg="pexpect.TIMEOUT: Unexpected timeout waiting for prompt or command: {0}".format(err))
except pexpect.EOF as err:
module.fail_json(msg="pexpect.EOF: Unexpected program termination: {0}".format(err))
except pexpect.exceptions.ExceptionPexpect as err:
# This catches any pexpect exceptions that are not EOF or TIMEOUT
# This is the base exception class
module.fail_json(msg="pexpect.exceptions.{0}: {1}".format(type(err).__name__, err))
except RuntimeError as err:
module.fail_json(msg="{0}".format(err))
def run_pexpect(commands, options, password, timeout=300):
import pexpect
changed = True
script_path = '/path/to/myscript.sh'
if not os.path.exists(script_path):
raise RuntimeError("Error: the script '{0}' does not exist!".format(script_path))
if script_path == '/path/to/myscript.sh':
raise RuntimeError("This module example is based on a hypothetical command line interactive program and "
"can not run. Please use this as a basis for your own development and testing.")
# Set prompt to expect with username embedded in it
# YOU MAY NEED TO CHANGE THIS PROMPT FOR YOUR SYSTEM
# My default RHEL prompt regex
prompt = r'\[{0}\@.+?\]\$'.format(getpass.getuser())
output = ""
child = pexpect.spawn('/bin/bash')
try:
# Look for initial bash prompt
child.expect(prompt)
# Start our program
child.sendline("{0} {1}".format(script_path, options))
# look for our scripts logfile prompt
# Example text seen in output: 'Logfile: /path/to/mylog.log'
child.expect(r'Logfile\:.+?/.+?\.log')
# Note that child.after contains the text of the matching regex
logfile = child.after.split()[1]
# Look for password prompt
i = child.expect([r"Enter password\:", '>'])
if i == 0:
# Send password
child.sendline(password)
child.expect('>')
# Increase timeout for longer running interactions after quick initial ones
child.timeout = timeout
try:
# Look for program internal prompt or new config dialog
i = child.expect([r'Initialize New Config\?', '>'])
# pexpect will return the index of the regex it found first
if i == 0:
# Answer 'y' to initialize new config prompt
child.sendline('y')
child.expect('>')
# If any commands were passed in loop over them and run them one by one.
for command in commands:
child.sendline(command)
i = child.expect([r'ERROR.+?does not exist', r'ERROR.+?$', '>'])
if i == 0:
# Attempt to intelligently add items that may have multiple instances and are missing
# e.g. "socket.2" may need "add socket" run before it.
# Try to allow the user just to use the set command and run add as needed
try:
new_item = child.after.split('"')[1].split('.')[0]
except IndexError:
raise RuntimeError("ERROR: unable to automatically add new item in myscript,"
" file a bug\n {0}".format(child.after))
child.sendline('add {0}'.format(new_item))
i = child.expect([r'ERROR.+?$', '>'])
if i == 0:
raise RuntimeError("ERROR: unable to automatically add new item in myscript,"
" file a bug\n {0}".format(child.after.strip()))
# Retry the failed original command after the add
child.sendline(command)
i = child.expect([r'ERROR.+?$', '>'])
if i == 0:
raise RuntimeError("ERROR: unable to automatically add new item in myscript,"
" file a bug\n {0}".format(child.after.strip()))
elif i == 1:
raise RuntimeError("ERROR: unspecified error running a myscript command\n"
" {0}".format(child.after.strip()))
# Set timeout shorter for final commands
child.timeout = 15
# If we processed any commands run the save function last
if commands:
child.sendline('save')
# Using true loops with expect statements allow us to process multiple items in a block until
# some kind of done or exit condition is met where we then call a break.
while True:
i = child.expect([r'No changes made', r'ERROR.+?$', '>'])
if i == 0:
changed = False
elif i == 1:
raise RuntimeError("ERROR: unexpected error saving configuration\n"
" {0}".format(child.after.strip()))
elif i == 2:
break
# Always print out the config data from out script and return it to the user
child.sendline('print config')
child.expect('>')
# Note that child.before contains the output from the last expected item and this expect
current_settings = child.before.strip()
# Run the 'exit' command that is inside myscript
child.sendline('exit')
# Look for a linux prompt to see if we quit
child.expect(prompt)
except pexpect.TIMEOUT:
raise RuntimeError("ERROR: timed out waiting for a prompt in myscript")
# Get shell/bash return code of myscript
child.sendline("echo $?")
child.expect(prompt)
# process the output into a variable and remove any whitespace
exit_status = child.before.split('\r\n')[1].strip()
if exit_status != "0":
raise RuntimeError("ERROR: The command returned a non-zero exit code! '{0}'\n"
"Additional info:\n{1}".format(exit_status, output))
child.sendline('exit 0')
# run exit as many times as needed to exit the shell or subshells
# This might be useful if you ran a script that put you into a new shell where you then ran some other scripts
# This is also a good example of
while True:
i = child.expect([prompt, pexpect.EOF])
if i == 0:
child.sendline('exit 0')
elif i == 1:
break
finally:
# Always try to close the pexpect process
child.close()
return current_settings, changed, logfile
if __name__ == '__main__':
main()
In order to create a module you need to put your new “mymodule.py” file somewhere in the Ansible module library path, typically the “library” directory next to your playbook or library inside your role. It’s also important to note that Ansible library modules run on the target ansible host, so if you want to use the ansible “expect” module or make a custom module with pexpect in it then you will need to install the python pexpect module on the remote host before running module. (Note: the pexpect version provided in RHEL/CentOS repos is old and will not support the Ansible “expect” module, install via pip instead for the latest version.)
Information on the library path is located here:
https://docs.ansible.com/ansible/latest/dev_guide/developing_locally.html
Your example.py file needs to be a standard file with a python shebang header and also import the ansible module. Here is a bare minimum amount of code needed for an ansible module.
#!/usr/bin/env python from ansible.module_utils.basic import AnsibleModule module = AnsibleModule(argument_spec=dict(mysetting=dict(required=False, type='str'))) try: return_value = "mysetting value is: {0}".format(module.params['mysetting']) except: module.fail_json(msg="Unable to process input variable into string") module.exit_json(changed=True, my_output=return_value)
With this example you can see how variables are passed into and out of the module. This also includes a basic exception handle for dealing with errors and allowing ansible to deal with the failure. This exception clause is too broad for normal use as it will catch and hide all errors that could happen in the try block. When you create your module you should only except error types that you anticipate to avoid hiding stack traces of unexpected errors from your logs.
Now we can add in some custom pexpect processing code. This is again a very basic example. The example code linked in this blog post has a complicated and in-depth example. This function would then be added into our try-except block in the code above.
def run_pexpect(password): import pexpect child = pexpect.spawn('/path/to/myscript.sh') child.timeout = 60 child.expect(r"Enter password\:") child.sendline(password) child.expect('Thank you') child.sendline('exit') child.expect(pexpect.EOF) exit_dialog = child.before.strip() return exit_dialog
There are some important things to note here when dealing with pexpect and Ansible.
- If the program hits a timeout it will raise “pexpect.TIMEOUT” and if it terminates unexpectedly it will raise “pexpect.EOF”. These exceptions will need to be either expected, with child.expect or excepted using pythons exception handling. Any other exceptions don’t really need to be handled as then are likely real errors that should cause failure and raise a stack trace.
- Always use a timeout! Be careful never to set the timeout to None as an unattended process will hang forever waiting on any new/unexpected prompt. It’s always better to set a very generous timeout over none at all. You can change the timeout multiple times in code based on how long you expect each prompt to take to come back.
- If you do not set a timeout value at all the default for the spawn class is 30 seconds. This is the timeout looking for the text in an expect method. Even if your program is outputting text to stdout, when the timeout is hit before the string is found then the program is killed and pexpect.TIMEOUT is raised.
- Don’t use print functions in python to try to send information back to Ansible. Printing to stdout with your module will cause ansible to register a failure. All output and information should be passed back through an Ansible method like “module.exit_json”.
- For debugging you may want to use the “child.logfile” facility to create log files on the remote system.
- The “child.expect” method takes regular expressions as input. If you want an explicit string you can always “import re” and use the “re.escape” method on a string to escape it.
When creating custom modules I would encourage you to give thought to making the simplest, most maintainable and modular modules possible. It can be easy to create one module/script to rule them all, but the linux concept of having one tool to do one thing well will save you rewriting chunks of code that do the same thing and also help future maintainers of the automation you create.
Helpful links:
https://docs.ansible.com/ansible/latest/modules/expect_module.html
https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_general.html
https://pexpect.readthedocs.io/en/stable/overview.html
If you have any questions about the steps documented here, would like more information on the custom development process, or have any feedback or requests, please let us know at [email protected].
Brad is an expert in automation using Ansible, Python and pexpect to develop custom solutions and automate the things that “can’t be automated”. Prior to Keyva, Brad worked at Cray R&D for 6 years and led automation efforts across their XC supercomputer development environment. Brad has a passion for learning new technology, technical problem solving and helping others.
Like what you read? Follow Brad on LinkedIn at: https://www.linkedin.com/in/bradejohnson/