How to Write an Ansible Module – Part 1

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!

Module Arguments and Code Entrypoint

As the first thing, we need to find a way to pass our module arguments (e.g., contactlocation, 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 osprovider 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_stringscontact 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_exclusiverequired_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()

Conclusion

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



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Author