How to Write an Ansible Module — Part 2

Federico Olivieri
10 min readSep 28, 2021

In the first part of this post — available here — we have covered the basic configuration and logic required in order to write an idempotent Ansible module. In this post, we will see how to run a series of tests good enough to make sure your module works as expected and — in case we want to open source our module — fulfill the Ansible community requirements to have our module merged into the Ansible collection if we wish to. Spoiler alert: Use smoke tests if you also want to debug your module!

There are three types of test that you need to set up and run. No surprise, they are the tests you would run every time you write a piece of code:

  • sanity test — “Let’s make it nice and consistent”
  • unit tests — “I run the test against some mock data”
  • smoke tests/integration tests — “Let’s see if my code works in the real world”

In order to take advantage of Ansible testing strategy (more on that later), we will use ios_ntp module under cisco.ios collection. As reference for this example, have a look here to gain an understanding of how ios_ntp module works. As always, documentation is a great resource, so we will be following Testing Collection provided by Ansible.

Sanity Tests

There is not much that needs to be done apart from running the tests themselves. In order to do that, we can leverage on ansible-test command which is part of ansible package. It’s a good idea to run the test in an isolated environment such as Docker. So let’s go ahead and run ansible-test sanity --docker default ios_ntp.

olivierif:ios olivierif$ ansible-test sanity --docker default ios_ntp
Running sanity test 'action-plugin-docs' with Python 3.6
Running sanity test 'ansible-doc' with Python 3.6
Running sanity test 'changelog' with Python 3.6
Running sanity test 'compile' with Python 2.6
[...]
Running sanity test 'import' with Python 3.9
Running sanity test 'line-endings' with Python 3.6
Running sanity test 'metaclass-boilerplate' with Python 3.6
Running sanity test 'no-assert' with Python 3.6
Running sanity test 'no-basestring' with Python 3.6
Running sanity test 'no-dict-iteritems' with Python 3.6
Running sanity test 'no-dict-iterkeys' with Python 3.6
Running sanity test 'no-dict-itervalues' with Python 3.6
Running sanity test 'no-get-exception' with Python 3.6
Running sanity test 'no-illegal-filenames' with Python 3.6
Running sanity test 'no-main-display' with Python 3.6
Running sanity test 'no-smart-quotes' with Python 3.6
Running sanity test 'no-unicode-literals' with Python 3.6
Running sanity test 'pep8' with Python 3.6
Running sanity test 'pslint'
[...]
Running sanity test 'validate-modules' with Python 3.6
Running sanity test 'yamllint' with Python 3.6

As we can see from the output, our module is spotless. In case there had been some error, that would have been flagged. As an example:

olivierif:ios olivierif$ ansible-test sanity --docker default ios_ntp
[...]
ERROR: Found 1 pylint issue(s) which need to be resolved:
ERROR: plugins/modules/ios_ntp.py:268:32: trailing-whitespace: Trailing whitespace
See documentation for help: https://docs.ansible.com/ansible/2.10/dev_guide/testing/sanity/pylint.html
Running sanity test 'replace-urlopen' with Python 3.6
[...]
Running sanity test 'validate-modules' with Python 3.6
Running sanity test 'yamllint' with Python 3.6
ERROR: The 2 sanity test(s) listed below (out of 45) failed. See error output above for details.
pep8
pylint

Integration tests are good because they can show you how the module behaves in a real environment. But what if you don’t have a Cisco device available where you can ssh in and push the configuration? That’s why unit tests exist. So let’s see what the Ansible strategy is for such tests.

Unit Tests

Unit tests are meant to run against some text stored in a file. So first of all, we need some mock device configuration lines. The example we are using is available here.

ntp logging
ntp authentication-key 10 md5 15435A030726242723273C21181319000A 7
ntp authenticate
ntp trusted-key 10
ntp source Loopback0
ntp access-group peer NTP_ACL
ntp server 10.75.32.5

That config snippet will be loaded during unit test run and will simulate the device configuration. In unit test, it will not be possible to test the ssh module functionality. But what we are interested in testing are the add and remove functions, as well as module idempotency.

Let’s now write the unit test which, once again, is taken from cisco.ios repo and is available here.

First, we import all the relative dependencies:

# Import all the necessary bits to run Ansible unit tests.
from ansible_collections.cisco.ios.tests.unit.compat.mock import patch
from ansible_collections.cisco.ios.tests.unit.modules.utils import (
set_module_args,
)
from .ios_module import TestIosModule, load_fixture
# Import ios_ntp module and all the relative functions
from ansible_collections.cisco.ios.plugins.modules import ios_ntp

Let’s now write the foundation for our test. We will create a new class called TestIosNtpModule with inheritance from TestIosModule. We then create a SetUp method (which will initialize all the required bits to run the test), a tearDown method (which will take care to… tear down the tests), and a load_fixture method (which will load the mock data written above). There is some magic behind the scenes of these methods, so we will not dig much into them. The few comment lines should help you grasp the functionality of each bit.

class TestIosNtpModule(TestIosModule):    module = ios_ntp    def setUp(self):
super(TestIosNtpModule, self).setUp()
# Look for get_config object from ios_ntp and mock it
self.mock_get_config = patch(
"ansible_collections.cisco.ios.plugins.modules.ios_ntp.get_config"
)
# Start mocked get_config
self.get_config = self.mock_get_config.start()
# Same here for load_config. Look up and mock.
self.mock_load_config = patch(
"ansible_collections.cisco.ios.plugins.modules.ios_ntp.load_config"
)
# Start mocked load_config
self.load_config = self.mock_load_config.start()
def tearDown(self):
super(TestIosNtpModule, self).tearDown()

# Tear down mocked objects for 'get_config' and 'load_config'
self.mock_get_config.stop()
self.mock_load_config.stop()

def load_fixtures(self, commands=None):
# Load mock config into get_config and return lines
self.get_config.return_value = load_fixture(
"ios_ntp_config.cfg"
).strip()
# Fake return of load_config
self.load_config.return_value = dict(diff=None, session="session")

Now that we have built the foundation of our unit tests, let’s go on to writing tests for idempotency, configuration and removal module functionality. Each method would need a set of arguments (the same as defined in the module) which will be used to generate the configuration. The configuration generated is compared against the loaded mock data. Based on that, we would need to pass the list of commands generated and the changed status. We can easily assume that the idempotency method will have commands = [] and changed=False

What about the others? Let’s have a look.

# Here the config generated is exactly the same as the mock data
# so there are no commands created and no change in status
def test_ios_ntp_idempotent(self):
set_module_args(
dict(
server="10.75.32.5",
source_int="Loopback0",
acl="NTP_ACL",
logging=True,
auth=True,
auth_key="15435A030726242723273C21181319000A",
key_id="10",
vrf="my_mgmt_vrf",
state="present",
)
)
commands = []
# Execute module and pass expected result and commands
self.execute_module(changed=False, commands=commands)
# Here we changed the server IP and source interface.
def test_ios_ntp_config(self):
set_module_args(
dict(
server="10.75.33.5",
source_int="Vlan2",
acl="NTP_ACL",
logging=True,
auth=True,
auth_key="15435A030726242723273C21181319000A",
key_id="10",
state="present",
)
)
commands = ["ntp server 10.75.33.5", "ntp source Vlan2"]
self.execute_module(changed=True, commands=commands)
# This bit will test 'remove' module functionality
def test_ios_ntp_remove(self):
set_module_args(
dict(
server="10.75.32.5",
source_int="Loopback0",
acl="NTP_ACL",
logging=True,
auth=True,
auth_key="15435A030726242723273C21181319000A",
key_id="10",
vrf="my_mgmt_vrf",
state="absent",
)
)
commands = [
"no ntp server my_mgmt_vrf 10.75.32.5",
"no ntp source Loopback0",
"no ntp access-group peer NTP_ACL",
"no ntp logging",
"no ntp authenticate",
"no ntp trusted-key 10",
"no ntp authentication-key 10 md5 15435A030726242723273C21181319000A 7",
]
self.execute_module(changed=True, commands=commands)

Let’s now run the unit tests, this time with more verbose output and using python3.6 only in centos8 docker image: ansible-test units --docker centos8 -v --python 3.6 tests/unit/modules/network/ios/test_ios_ntp.py.

[...]
2021-08-13 17:06:18.992334 | centos-8 | tests/unit/modules/network/ios/test_ios_ntp.py::TestIosNtpModule::test_ios_ntp_config
2021-08-13 17:06:18.992377 | centos-8 | [gw0] [ 52%] PASSED tests/unit/modules/network/ios/test_ios_ntp.py::TestIosNtpModule::test_ios_ntp_config
2021-08-13 17:06:19.060454 | centos-8 | tests/unit/modules/network/ios/test_ios_ntp.py::TestIosNtpModule::test_ios_ntp_idempotent
2021-08-13 17:06:19.060498 | centos-8 | [gw0] [ 53%] PASSED tests/unit/modules/network/ios/test_ios_ntp.py::TestIosNtpModule::test_ios_ntp_idempotent
2021-08-13 17:06:19.128827 | centos-8 | tests/unit/modules/network/ios/test_ios_ntp.py::TestIosNtpModule::test_ios_ntp_remove
2021-08-13 17:06:19.128888 | centos-8 | [gw0] [ 53%] PASSED tests/unit/modules/network/ios/test_ios_ntp.py::TestIosNtpModule::test_ios_ntp_remove
[...]

All good then!

Smoke Tests & Integration Tests

In this specific case, we are writing an Ansible module. In order to see if it works, we need to put our new module in a playbook and…run it! This can be useful during module development in order to debug our code and see what is returned by the device or the module itself.

First things first: let’s clean up device configuration (taking Cisco IOS, for example) and remove all the lines that we will try to push during our tests. Note that we will use yaml anchors and aliases to make our playbook DRY. More info available here.

Integration tests are usually located under tests/integration/targets/ios_ntp/tests/cli/ntp_configuration.yaml. You can find the full playbook on this link.

Through the playbook, we will heavily use yaml anchors; so make sure to have a good understanding of them! Once more, docs comes to our help!

# Clean up device config if needed. We anchor the task with `&id007`
# and refer to it at the end of the playbook.
- name: remove NTP (if set)
ignore_errors: true
cisco.ios.ios_ntp: &id007
server: 10.75.32.5
source_int: '{{ test_interface }}'
acl: NTP_ACL
logging: true
key_id: 10
auth_key: 15435A030726242723273C21181319000A
auth: true
state: absent
provider: '{{ cli }}'

Now that we have clean configuration, let’s test each module argument and functionality one by one. A lot of code ahead, but an example is worth more than a thousand words!

- block:
# Add NTP server config only and anchor the task - &id001.
- name: "10000 - CONFIGURE NTP SERVER"
register: result
cisco.ios.ios_ntp: &id001
server: '10.75.32.5'
source_int: '{{ test_interface }}'
state: 'present'
provider: '{{ cli }}'
# Assert that the change ran successfully. You can also assert
# other returned values like result.stdout == "ntp server 10.75.32.5 source-interface Loopback0".
- assert: &id002
that:
- result.changed == true
# Check if module is idempotent referring task 10005
# in brief: repeat task '10000 - CONFIGURE NTP SERVER'.
- name: "10005 - IDEMPOTENCY CHECK"
register: 'result'
cisco.ios.ios_ntp: *id001
# Asset that no changes have been applied.
- assert: &id004
that:
- result.changed == false
# Add NTP access-list line to later apply to NTP config.
- name: "10010 - LOAD NTP NTP_ACL INTO DEVICE"
register: result
cisco.ios.ios_config:
lines:
- '10 permit ip host 192.0.2.1 any log'
parents: 'ip access-list extended NTP_ACL'
provider: '{{ cli }}'
# Repeat assertion result.changed == true
- assert: *id002
# Add NTP access-list line to later apply to NTP config.
- name: "10015 - CONFIGURE NTP ACL"
register: result
cisco.ios.ios_ntp: &id003
acl: 'NTP_ACL'
logging: true
state: 'present'
provider: '{{ cli }}'
# Repeat assertion result.changed == true
- assert: *id002
- name: "10020 - IDEMPOTENCY CHECK"
register: 'result'
cisco.ios.ios_ntp: *id003
- assert: *id004
# You get the idea, right? Repeat the pattern for
# all your module arguments.
# [...]
# If even one of our tasks above fails, we want to remove the config
# so we can assure clean configuration at the next run
always:
- name: '20000 - REMOVE NTP CONFIG'
cisco.ios.ios_ntp: *id007
- name: 20005 - REMOVE NTP_ACL CONFIG
cisco.ios.ios_config:
lines:
- 'no ip access-list extended NTP_ACL'
provider: '{{ cli }}'

The way you would run the test is very similar to the sanity ones: ansible-test integration --docker Remember, however, as the documentation points out, we need to have a valid inventory just as when running a normal playbook. At the end of the day, our integration tests playbook…is just a playbook. Below is a snippet of the output from integration tests.

2021-08-13 17:49:49.758183 | centos-8 | TASK [ios_ntp : configure NTP] *************************************************
2021-08-13 17:49:51.428640 | centos-8 | changed: [ios-15.6-2T] => {
2021-08-13 17:49:51.428665 | centos-8 | "changed": true,
2021-08-13 17:49:51.428671 | centos-8 | "commands": [
2021-08-13 17:49:51.428675 | centos-8 | "ntp server 10.75.32.5",
2021-08-13 17:49:51.428680 | centos-8 | "ntp source GigabitEthernet0/1"
2021-08-13 17:49:51.428685 | centos-8 | ],
2021-08-13 17:49:51.428689 | centos-8 | "invocation": {
2021-08-13 17:49:51.428694 | centos-8 | "module_args": {
2021-08-13 17:49:51.428698 | centos-8 | "acl": null,
2021-08-13 17:49:51.428702 | centos-8 | "auth": false,
2021-08-13 17:49:51.428706 | centos-8 | "auth_key": null,
2021-08-13 17:49:51.428710 | centos-8 | "key_id": null,
2021-08-13 17:49:51.428715 | centos-8 | "logging": false,
2021-08-13 17:49:51.428719 | centos-8 | "provider": null,
2021-08-13 17:49:51.428752 | centos-8 | "server": "10.75.32.5",
2021-08-13 17:49:51.428757 | centos-8 | "source_int": "GigabitEthernet0/1",
2021-08-13 17:49:51.428762 | centos-8 | "state": "present",
2021-08-13 17:49:51.428773 | centos-8 | "vrf": null
2021-08-13 17:49:51.428782 | centos-8 | }
2021-08-13 17:49:51.428787 | centos-8 | }
2021-08-13 17:49:51.428791 | centos-8 | }
2021-08-13 17:49:51.434004 | centos-8 |
2021-08-13 17:49:51.434028 | centos-8 | TASK [ios_ntp : assert] ********************************************************
2021-08-13 17:49:52.052619 | centos-8 | ok: [ios-15.6-2T] => {
2021-08-13 17:49:52.052637 | centos-8 | "changed": false,
2021-08-13 17:49:52.052643 | centos-8 | "msg": "All assertions passed"
2021-08-13 17:49:52.052648 | centos-8 | }
2021-08-13 17:49:52.056361 | centos-8 | TASK [ios_ntp : idempotence check] *********************************************
2021-08-13 17:49:53.250647 | centos-8 | ok: [ios-15.6-2T] => {
2021-08-13 17:49:53.250676 | centos-8 | "changed": false,
2021-08-13 17:49:53.250683 | centos-8 | "commands": [],
2021-08-13 17:49:53.250689 | centos-8 | "invocation": {
2021-08-13 17:49:53.250695 | centos-8 | "module_args": {
2021-08-13 17:49:53.250701 | centos-8 | "acl": null,
2021-08-13 17:49:53.250706 | centos-8 | "auth": false,
2021-08-13 17:49:53.250712 | centos-8 | "auth_key": null,
2021-08-13 17:49:53.250718 | centos-8 | "key_id": null,
2021-08-13 17:49:53.250724 | centos-8 | "logging": false,
2021-08-13 17:49:53.250729 | centos-8 | "provider": null,
2021-08-13 17:49:53.250736 | centos-8 | "server": "10.75.32.5",
2021-08-13 17:49:53.250742 | centos-8 | "source_int": "GigabitEthernet0/1",
2021-08-13 17:49:53.250748 | centos-8 | "state": "present",
2021-08-13 17:49:53.250754 | centos-8 | "vrf": null
2021-08-13 17:49:53.250760 | centos-8 | }
2021-08-13 17:49:53.250766 | centos-8 | }
2021-08-13 17:49:53.250772 | centos-8 | }
2021-08-13 17:49:53.254216 | centos-8 |
2021-08-13 17:49:53.254233 | centos-8 | TASK [ios_ntp : assert] ********************************************************
2021-08-13 17:49:53.832621 | centos-8 | ok: [ios-15.6-2T] => {
2021-08-13 17:49:53.832639 | centos-8 | "changed": false,
2021-08-13 17:49:53.832645 | centos-8 | "msg": "All assertions passed"
2021-08-13 17:49:53.832657 | centos-8 | }
2021-08-13 17:49:53.835458 | centos-8 | redirecting (type: action) cisco.ios.ios_config to cisco.ios.ios

Conclusions

I found this experience with Ansible module very interesting, even though it’s a bit tough to understand at first because there are some magic parts that you might need to figure out first. I hope you have enjoyed this journey, as I did. And now that you are settled, why not submit a PR to have your module merged into the public Ansible collection?

Federico

--

--

Federico Olivieri

Network Automation Engineer with a strong passion in mechanical engineer and exploring the unknown. What is it better than travel around the world with a Vespa?