You certainly remember the first time you ran an Ansible playbook: a few lines of code grouped together in a bunch of tasks
that did a lot of magic. Struck by curiosity, you surely asked yourself what could be concealed behind that thing called module
. How was it possible that a few key/value pairs could abstract what usually would have been tens of lines of bash code? Well, we know that in software engineering there is not such a thing as magic, same is true for Ansible and Ansible modules.
From Ansible documentation a module
is:
“a reusable, standalone script that Ansible runs on your behalf, either locally or remotely. Modules interact with your local machine, an API, or a remote system to perform specific tasks…A module provides a defined interface, accepts arguments, and returns information to Ansible by printing a JSON string to stdout before exiting.”
This is the part we are interestd in (still from Ansible docs):
If you need functionality that is not available in any of the thousands of Ansible modules found in collections, you can easily write your own custom module. When you write a module for local use, you can choose any programming language and follow your own rules.
In short, a module is an abstraction layer that hides a piece of code which interacts with a machine. If you do not like what is already available on Ansible repo or you need something different, you can still easily write your own module.
Taking that into account, in this and following posts, we will see how to write an Ansible module applying NTC best practices. In this first part of the post, we will go through all the steps required to write a Cisco IOS and NXOS SNMP module example called ntc_snmp
, where we will be configuring SNMP community strings, contact, and location. As a requirement, the module should be idempotent and gracefully error out if any OS is passed in that is not IOS or NXOS.
Ansible task example:
ntc_snmp:
os: ""
provider:
username: "ntc_user"
password: "ntc_password"
hostname: ""
community_strings:
- type: "ro"
string: "public"
contact: "info@networktocode.com"
location: "New_York"
replace: true
Let’s roll up our sleeves and dive into it!
As the first thing, we need to find a way to pass our module arguments (e.g., contact
, location
, etc.) to our code. We can easily do that with AnsibleModule
import which gives us a nice dictionary with all the bits we need to draft our module.
Let’s have a look at the import
part and the module arguments:
#!/usr/bin/python3
try:
from netmiko import ConnectHandler
HAS_NETMIKO = True
except ImportError:
HAS_NETMIKO = False
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.basic import missing_required_lib
def main():
if not HAS_NETMIKO:
module.fail_json(msg=missing_required_lib("netmiko"))
provider_options = dict(
username=dict(required=True),
password=dict(required=True),
hostname=dict(required=True),
)
community_options = dict(
type=dict(required=True, choices=['ro','rw']),
string=dict(required=True, no_log=True),
)
argument_spec = dict(
os=dict(type='list', required=True, choices=['ios','nxos']),
provider=dict(type='dict', required=True, options=provider_options),
community_strings=dict(type='list', elements='dict', subrequired=True, options=community_options),
contact=dict(required=False),
location=dict(required=False),
replace=dict(type='bool', default=False)
)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=[['community_strings', 'contact', 'location']],
)
Let’s pause for a second and analyse the above code.
In this example we use Netmiko to handle SSH session, so first we want to check whether the library is installed. We are doing this with a try
and except
to create a boolean constant that we will use later to validate whether the library is installed. We also import a couple of Ansible utilities such as AnsibleModule
and missing_required_lib
which will make our life much easier later on. In this example we leverage just a couple of module_utils
imports but I encourage you to explore here to see what’s available, as there are some that can do a lot of heavy lifting for you.
We then define our module entrypoint main()
, and we create a dictionary called argument_spec
that contains our key/value pairs from module arguments where value is a dictionary containing some kwargs that define the type of module argument accepted. In our example, we have required=True
for os
, provider
and community_strings
which means that these are mandatory arguments to be passed to our module. If the argument data type is something different from a string, you would need to specify that, as we have done in some of them: type='list'
, type='bool'
, or type='dict'
(note that for list
we also specified the elements
type). You can also narrow down to a set of possible choices as we did in os
(i.e., choices=["ios", "nxos"]
) and set a default value as in replace
(i.e., default=False
).
In short argument_spec
acts like a schema validation, ensuring proper variables are passed into the modules as arguments. Here you can explore all the options available.
Looking at Ansible docs we can see that AnsibleModule
takes different types of kwargs. For the moment, we pass argument_spec
which contains our module arguments; we set to supports_check_mode=True
, so our module can also run in dry mode (so now you know what is behind ansible-playbook foo.yml --check
); and we do a kind of assertion with required_one_of
to have at least community_strings
, contact
or location
specified in our module. In short, we need to push on our device at least one of those three options – otherwise, what would be the purpose of this module?. I encourage you to look also into mutually_exclusive
, required_together
which are a powerful way to enforce a particular schema to your module. Check this out for some examples.
Before we continue coding, we need to understand how idempotency workflow is structured on Ansible. Whenever we run a module, Ansible first look into the piece of the device or server configuration we intend to change. If the actual configuration is different from our intended configuration, the changes are applied; otherwise the task is skipped.
Based on our example, let’s now assume that we want to change the snmp location. The first thing that the module does is to build the intended configuration from the module argument. Then it will run show runninng-config
command, find the snmp location line in the configuration, and compare the config line that we have (actual running config) with what we want (intended). Not 100% clear? Don’t worry, it will make more sense as we will build our code.
Let’s create a function for cisco IOS called ios_map_config_to_obj
. We will pass an SSH session (created in a separate function) to it. Then we will ssh into our device, get the SNMP config lines, do some parsing (if required), and map the config into a dictionary. The code is quite self-explanatory so, let’s have a look to it:
def ios_map_config_to_obj(ssh_session):
# initiate defualt "have" dict()
have = {
'location': None,
'contact': None,
'community_strings': None,
}
# send show command for "location" and map into dictionary
location_output = ssh_session.send_command("show snmp location")
if location_output:
have['location'] = location_output
# send show command for "contact" and map into dictionary
contact_output = ssh_session.send_command("show snmp contact")
if contact_output:
have['contact'] = contact_output
# initiate "community" dict() as we might have more than one community
community_dict = dict()
# send show command for "community"
community_output = ssh_session.send_command("show running-config | include snmp-server community")
# parse "community" output and map into "community_output" dict()
if community_output:
for comm in community_output.splitlines():
comm_string = comm.split()[-2]
comm_type = comm.split()[-1]
community_dict[comm_string] = comm_type
# update "have" dict() with "community_output" dict()
have['community_strings'] = community_dict
# "have" dict() example
#
# have = {
# "community_strings": {
# "ntc-private": "RW",
# "ntc-public": "RO",
# "public": "RW",
# "testro": "RW",
# "testwr": "RW"
# },
# "contact": "Mike",
# "location": "VG"
# }
return have
As you can see, we have run a bunch of show commands and we map them into a dictionary. That will make it easier to compare the actual config with the intended one and apply the principle of idempotency.
Moving forward, we now have to map our module argument into actual IOS commands that later will be sent to the device. Let’s define a function called ios_map_obj_to_com
and pass AnsibleModule
(which holds our module arguments) as well as our returned dict from ios_map_config_to_obj
.
For the sake of brevity, we will take into account just contact
and location
config lines. With the help of comment lines, the code should be quite self-explanatory.
def ios_map_obj_to_commands(module, have):
# module args unpacking
want_contact = module.params.get('contact')
want_location = module.params.get('location')
want_replace = module.params.get('replace')
# have dict() unpacking
have_contact = have.get('contact')
have_location = have.get('location')
commands = list()
# from module args, if we don't want to replace running-config
if not replace:
# if "contact" in module arg is different from "contact" in running-config,
# append new config line to command list
if want_contact != have_contact:
commands.append('snmp-server contact {0}'.format(want_contact))
# if "location" in module arg is different from "location" in running-config,
# append new config line to command list
if want_location != have_location:
commands.append('snmp-server location {0}'.format(want_location))
# return list of commands to push to device
return commands
# from module args: if we want to replace running-config
if replace:
# if we do not have "contact" in running-config and we have "contact" in module args
if want_contact is None and have_contact:
commands.append('no snmp-server contact {0}'.format(have_contact))
# if we have "contact" in running-config and it's different from "contact" in module args
if (want_contact is not None) and (want_contact != have_contact):
commands.append('snmp-server contact {0}'.format(want_contact))
# same above logic applies to "location"
if want_location is None and have_location:
commands.append('no snmp-server location {0}'.format(have_location))
if (want_location is not None) and (want_location != have_location):
commands.append('snmp-server location {0}'.format(want_location))
# return list of commands to push to device
return commands
First, we unpack the want
arguments (module arguments) as well as the have
arguments. Based on replace
module argument, we trigger different logic and compare what we have
against what we want
. Based on that, we append a config line to a list that later will be pushed to the device – simple as that.
Let’s now push our configuration and generate the famous Ansible result
in json format.
Under our mian()
function we add:
# This is required in order to call the right function based on OS
want_call_dict = {
'ios': ios_map_obj_to_commands,
'nxos': nxos_map_obj_to_commands,
}
# Same here
have_call_dict = {
'ios': ios_map_config_to_obj,
'nxos': nxos_map_config_to_obj,
}
# result dict() initialization with default changed=False
result = dict(changed=False)
# call the right have function based on OS. Pass ssh_session as arg
have = have_call_dict[module.params.get('os')[0]](ssh_session(module))
# call the right want function based on OS. Pass have result and module args
want = want_call_dict[module.params.get('os')[0]](module, have)
# if we have want config...
if want:
# ...and not check_mode...
if not module.check_mode:
# ...then push cofig to device...
ssh_session(module).send_config_set(want)
# ...and update result dict()
result.update(
changed=True,
have=have,
cmds=want,
)
# return result dict() in json format.
if result:
module.exit_json(**result)
if __name__ == "__main__":
main()
And that’s all for the first part of this post. In the second part we will see how to write DOCUMENTATION
and EXAMPLES
as well as how to build and run some tests for our module.
Federico
Share details about yourself & someone from our team will reach out to you ASAP!