Skip to content

Lab 09 - Creating a Custom Module for Making Changes to the IOS-XE REST API

In this lab exercise you will create a custom module for Ansible that performs changes against the IOS-XE RESTCONF API endpoints, specifically to manage VRF configuration.

Task 1 - Build the VRF REST Module

To start with, you will create a new module that makes changes to a specific VRF's description via the API.

Step 1

In your terminal on the lab workstation, change into the /home/ntc/labs/lab09 folder:

ntc@ntc-training:~$ cd /home/ntc/labs/lab09
ntc@ntc-training:lab09$

Step 2

Create a folder named modules and in it a file called iosxe_rest_vrf.py.

ntc@ntc-training:lab09$ mkdir modules
ntc@ntc-training:lab09$ touch modules/iosxe_rest_vrf.py
ntc@ntc-training:lab09$ tree
.
├── ansible.cfg
└── modules
    └── iosxe_rest_vrf.py

1 directory, 2 files

Step 3

Open the modules/iosxe_rest_vrf.py file in your editor and add the following code:

#!/usr/bin/env python3

DOCUMENTATION = """
"""

EXAMPLES = """
"""

RETURN = """
"""

from ansible.module_utils.basic import AnsibleModule
import requests
import json

requests.packages.urllib3.disable_warnings()

def main():
    pass

if __name__ == "__main__":
    main()

This is just the scaffolding for the module and over the following steps you will build its full functionality and documentation.

Step 4

In the modules/iosxe_rest_vrf.py file, change the EXAMPLES string to the following value.

EXAMPLES = """
# Collect Device Version
- name: UPDATE VRF DESCRIPTION
  iosxe_rest_vrf:
    host: "{{ inventory_hostname }}"
    user: "{{ username }}"
    password: "{{ password }}"
    name: CORP
    description: VRF FOR CORPORATE USERS
"""

You can use this to build a visual representation of how the module would be used - it helps to work out what parameters it needs when called from a playbook.

Since the module will be sending REST calls to an authenticated API, you need the device's IP or hostname (the host parameter), and the user and password.

As for the other parameters, the goal here is to provide an abstracted interface to make changes to a VRF's description. So you need to provide a VRF name and a VRF description.

Step 5

In the modules/iosxe_rest_vrf.py file, change the DOCUMENTATION string to the following value.

DOCUMENTATION = """
---
module: iosxe_rest_vrf
short_description: This module updates VRF configuration via the IOSXE REST API.
version_added: "1.0.0"
description: This module uses the requests Python library to make API calls against the IOSXE device.
options:
    host:
        description: Enter the IP or hostname of the IOSXE device
        required: true
        type: str
    user:
        description: Enter the device username
        required: true
        type: str
    password:
        description: Enter the device password
        required: true
        type: str
    verify:
        description: Enable or disable SSL verification, it will disabled by default
        required: false
        type: bool
    name:
        description: The VRF's name you want to manage
        required: true
        type: str
    description:
        description: The VRF's description (optional)
        required: false
        type: str

author:
    - NetworkToCode (@networktocode)
"""

This is what ansible-doc will use to generate the documentation page for the custom module and is a more programmatic way of defining each parameter you sketched out in the previous step's EXAMPLES.

Take special notice of each parameter's metadata - i.e. required, type, and description.

Step 6

Continue editing the file and replace the pass statement (which does nothing) in the main() function as shown below.

def main():
    # define available arguments/parameters a user can pass to the module
    module_args = dict(
        host=dict(type="str", required=True),
        user=dict(type="str", required=True),
        password=dict(type="str", required=True, no_log=True),
        verify=dict(type="bool", default=False),
        name=dict(type="str", required=True),
        description=dict(type="str", required=False),
    )
    module = AnsibleModule(argument_spec=module_args, supports_check_mode=False)

    # prepare the standard return object
    result = dict(changed=False)

The module_args dictionary holds the argument spec or the actual code that specifies the module parameters as detailed in the DOCUMENTATION string earlier. This is used by the Ansible engine during execution.

Since this module does not yet support check mode, we set that when instantiating the AnsibleModule class. Finally, result is a dictionary that holds the data you wish the module to return to the Ansible engine once it finishes execution.

The module_args and the DOCUMENTATION string should be kept in sync! Any discrepancies will only cause confusion to users of your module.

Step 7

Update the RETURN string with the following code:

RETURN = """
# These are examples of possible return values, and in general should use other names for return values.
sent_payload:
    description: The VRF data that will be sent to the API via the PATCH request
    type: str
    returned: always
    sample: '{"Cisco-IOS-XE-native:definition": {"description": "VRF FOR CORPORATE USERS", "name": "CORP"}}}'
msg:
    description: A status message including the HTTP response code.
    type: str
    returned: always
    sample: 'OK 204'
"""

This is used by documentation to explain what additional data the module is returning - here the plan is to return sent_payload (the JSON API payload built for the request) and msg which is a custom message that includes the HTTP response code (e.g. 204 or 404).

Step 8

In the /home/ntc/labs/lab09 folder, verify you have a file called ansible.cfg with the lines below. You need to tell Ansible where to find your custom module - in this case you're telling it to search in the current path for a folder called modules.

# Instruct Ansible to look locally for modules
library = modules

Step 9

Verify that your folder structure looks like the output below.

ntc@ntc-training:lab09$ tree
.
├── ansible.cfg
└── modules
    └── iosxe_rest_vrf.py

1 directory, 2 files

Step 10

Verify where Ansible looks for modules, also called the library or module search path.

ntc@ntc-training:lab09$ ansible --version
ansible [core 2.11.6] 
  config file = /home/ntc/labs/lab09/ansible.cfg
  configured module search path = ['/home/ntc/labs/lab09/modules']
  ansible python module location = /usr/local/lib/python3.8/site-packages/ansible
  ansible collection location = /home/ntc/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/local/bin/ansible
  python version = 3.8.12 (default, Oct 13 2021, 09:22:51) [GCC 8.3.0]
  jinja version = 3.0.2
  libyaml = True

Step 11

Since the module's documentation is fully defined, you can already inspect it using the ansible-doc command. Notice that the output is built from the information you defined in the DOCUMENTATION, EXAMPLES, and RETURN strings inside of your custom module file.

ntc@ntc-training:lab09$ ansible-doc iosxe_rest_vrf
> IOSXE_REST_VRF    (/home/ntc/labs/lab09/modules/iosxe_rest_vrf.py)

        This module uses the requests Python library to make API calls against the IOSXE device.

  * This module is maintained by The Ansible Community
OPTIONS (= is mandatory):

- description
        The VRF's description (optional)
        [Default: (null)]
        type: str

= host
        Enter the IP or hostname of the IOSXE device

        type: str

= name
        The VRF's name you want to manage

        type: str

= password
        Enter the device password

        type: str

= user
        Enter the device username

        type: str

- verify
        Enable or disable SSL verification, it will disabled by default
        [Default: (null)]
        type: bool


AUTHOR: NetworkToCode (@networktocode)
        METADATA:
          status:
          - preview
          supported_by: community


EXAMPLES:

# Collect Device Version
- name: UPDATE VRF DESCRIPTION
  iosxe_rest_vrf:
    host: "{{ inventory_hostname }}"
    user: "{{ username }}"
    password: "{{ password }}"
    name: CORP
    description: VRF FOR CORPORATE USERS


RETURN VALUES:

# These are examples of possible return values, and in general should use other names for return values.
sent_payload:
    description: The VRF data that will be sent to the API via the PATCH request
    type: str
    returned: always
    sample: '{"Cisco-IOS-XE-native:definition": {"description": "VRF FOR CORPORATE USERS", "name": "CORP"}}}'
msg:
    description: A status message including the HTTP response code.
    type: str
    returned: always
    sample: 'OK 204'

Step 12

Time to build the actual REST API call! Edit the iosxe_rest_vrf.py file and add the following code to the end of the main() function definition.

    # set up the API request parameters
    auth = requests.auth.HTTPBasicAuth(module.params["user"], module.params["password"])
    # this module supports only json
    headers = {
        "Accept": "application/yang-data+json",
        "Content-Type": "application/yang-data+json",
    }
    # this module supports only the Cisco-IOS-XE-native data model
    base_url = (
        f"https://{module.params['host']}/restconf/data/Cisco-IOS-XE-native:native"
    )
    # manages vrf by name
    url = f"{base_url}/Cisco-IOS-XE-native:vrf/definition={module.params['name']}"

    # build PATCH request payload
    payload = {"Cisco-IOS-XE-native:definition": {"name": f"{module.params['name']}"}}

    # only set if it's defined since an empty description is not acceptable by the API
    if module.params["description"]:
        payload["Cisco-IOS-XE-native:definition"][
            "description"
        ] = f"{module.params['description']}"

    # add this to the module output for easier troubleshooting
    result["sent_payload"] = payload

This code builds the necessary variables needed to send an HTTP API call (URL, data, headers, authentication) from both hard-coded values (e.g. we only support JSON data) and dynamic module parameters (e.g. VRF name, description, hostname, user, password).

Step 13

The URL and payload are built by referencing an example output from the IOS-XE API for the VRF object. Take some time to compare the structure below to the code added in the previous step.

{
  "Cisco-IOS-XE-native:vrf": {
    "definition": [
      {
        "name": "CORP",
        "description": "VRF for Corporate Users"
      },
      {
        "name": "MANAGEMENT",
        "address-family": {
          "ipv4": {
          },
          "ipv6": {
          }
        }
      }
    ]
  }
}

Step 14

Finally, add this block of code at the end of the main() function. This actually sends the request to the API and handles the results accordingly.

    # send the API request to the device (PATCH)
    response = requests.patch(
        url,
        data=json.dumps(payload),
        headers=headers,
        auth=auth,
        verify=module.params["verify"],
    )

    # return the json response from the API if it exists
    if response.text:
        result["json"] = response.json()

    # since it's a blind PATCH request, always assume a change was made
    if response.ok:
        result["changed"] = True
        result["msg"] = f"OK {response.status_code}"
    # the request failed so the module should fail as well
    else:
        result["msg"] = f"API request failed with code {response.status_code}"
        module.fail_json(**result)

    # return successfully and pass the results to ansible
    module.exit_json(**result)

If the API request is successful (response.ok is True), then inform Ansible that the modules has performed a change.

In case of a failure (API is unavailable, authentication fails, resource does not exist etc.), you must also inform Ansible that the module has failed and provide an error message.

Step 15

The full iosxe_rest_vrf.py file is included below for reference - compare it with your own before going further!

#!/usr/bin/env python3

DOCUMENTATION = """
---
module: iosxe_rest_vrf
short_description: This module updates VRF configuration via the IOSXE REST API.
version_added: "1.0.0"
description: This module uses the requests Python library to make API calls against the IOSXE device.
options:
    host:
        description: Enter the IP or hostname of the IOSXE device
        required: true
        type: str
    user:
        description: Enter the device username
        required: true
        type: str
    password:
        description: Enter the device password
        required: true
        type: str
    verify:
        description: Enable or disable SSL verification, it will disabled by default
        required: false
        type: bool
    name:
        description: The VRF's name you want to manage
        required: true
        type: str
    description:
        description: The VRF's description (optional)
        required: false
        type: str

author:
    - NetworkToCode (@networktocode)
"""

EXAMPLES = """
# Collect Device Version
- name: UPDATE VRF DESCRIPTION
  iosxe_rest_vrf:
    host: "{{ inventory_hostname }}"
    user: "{{ username }}"
    password: "{{ password }}"
    name: CORP
    description: VRF FOR CORPORATE USERS
"""

RETURN = """
# These are examples of possible return values, and in general should use other names for return values.
sent_payload:
    description: The VRF data that will be sent to the API via the PATCH request
    type: str
    returned: always
    sample: '{"Cisco-IOS-XE-native:definition": {"description": "VRF FOR CORPORATE USERS", "name": "CORP"}}}'
msg:
    description: A status message including the HTTP response code.
    type: str
    returned: always
    sample: 'OK 204'
"""

from ansible.module_utils.basic import AnsibleModule
import requests
import json

requests.packages.urllib3.disable_warnings()


def main():
    # define available arguments/parameters a user can pass to the module
    module_args = dict(
        host=dict(type="str", required=True),
        user=dict(type="str", required=True),
        password=dict(type="str", required=True, no_log=True),
        verify=dict(type="bool", default=False),
        name=dict(type="str", required=True),
        description=dict(type="str", required=False),
    )
    module = AnsibleModule(argument_spec=module_args, supports_check_mode=False)

    # prepare the standard return object
    result = dict(changed=False)

    # set up the API request parameters
    auth = requests.auth.HTTPBasicAuth(module.params["user"], module.params["password"])
    # this module supports only json
    headers = {
        "Accept": "application/yang-data+json",
        "Content-Type": "application/yang-data+json",
    }
    # this module supports only the Cisco-IOS-XE-native data model
    base_url = (
        f"https://{module.params['host']}/restconf/data/Cisco-IOS-XE-native:native"
    )
    # manages vrf by name
    url = f"{base_url}/Cisco-IOS-XE-native:vrf/definition={module.params['name']}"

    # build PATCH request payload
    payload = {"Cisco-IOS-XE-native:definition": {"name": f"{module.params['name']}"}}

    # only set if it's defined since an empty description is not acceptable by the API
    if module.params["description"]:
        payload["Cisco-IOS-XE-native:definition"][
            "description"
        ] = f"{module.params['description']}"

    # add this to the module output for easier troubleshooting
    result["sent_payload"] = payload

    # send the API request to the device (PATCH)
    response = requests.patch(
        url,
        data=json.dumps(payload),
        headers=headers,
        auth=auth,
        verify=module.params["verify"],
    )

    # return the json response from the API if it exists
    if response.text:
        result["json"] = response.json()

    # since it's a blind PATCH request, always assume a change was made
    if response.ok:
        result["changed"] = True
        result["msg"] = f"OK {response.status_code}"
    # the request failed so the module should fail as well
    else:
        result["msg"] = f"API request failed with code {response.status_code}"
        module.fail_json(**result)

    # return successfully and pass the results to ansible
    module.exit_json(**result)


if __name__ == "__main__":
    main()

Task 2 - Use the VRF Module in an Ansible Playbook

Here you will start using the basic VRF module you created to make some changes to a CSR's configuration.

Step 1

In the /home/ntc/labs/lab09 folder, create a new file called pb_vrf.yml with the content below.

---

- name: TESTING THE IOSXE_REST_VRF MODULE
  hosts: localhost
  gather_facts: false

  tasks:
    - name: UPDATE DESCRIPTION FOR CORP VRF
      iosxe_rest_vrf:
        host: csr1
        user: ntc
        password: ntc123
        name: CORP
        description: VRF FOR CORPORATE USERS

Note: All the necessary data is embedded into the playbook, since this is early testing!

Step 2

Run the playbook pb_vrf.yml. It will fail.

ntc@ntc-training:lab09$ ansible-playbook pb_vrf.yml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the
implicit localhost does not match 'all'

PLAY [TESTING THE IOSXE_REST_VRF MODULE] *************************************************

TASK [UPDATE DESCRIPTION FOR CORP VRF] ***************************************************
fatal: [localhost]: FAILED! => {
    "changed": false,
    "json": {
        "errors": {
            "error": [
                {
                    "error-message": "patch to a nonexistent resource",
                    "error-path": "/Cisco-IOS-XE-native:native/vrf/definition=CORP",
                    "error-tag": "invalid-value",
                    "error-type": "application"
                }
            ]
        }
    },
    "sent_payload": {
        "Cisco-IOS-XE-native:definition": {
            "description": "VRF FOR CORPORATE USERS",
            "name": "CORP"
        }
    }
}

MSG:

API request failed with code 404

PLAY RECAP *******************************************************************************
localhost                  : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

Why did it fail? Well, the PATCH request needs to modify an existing VRF. Since the CORP VRF doesn't actually exist on the csr1 device, the API call fails. Inspect the error message, do you see the "patch to a nonexistent resource" text in there?

Look at the rest of the information provided, it is coming from your code! There's sent_payload in there showing you the request data, and there's msg showing the error code 404. The json field actually contains the full response given by the API.

Step 3

In a separate terminal on the lab workstation, open an SSH connection to the csr1 device. Verify the configuration (there's no VRF CORP yet!) and manually add the CORP VRF into the configuration.

ntc@ntc-training:lab09$ ssh csr1
Warning: Permanently added 'csr1,172.18.0.6' (RSA) to the list of known hosts.
Password:


csr1#sh run vrf
Building configuration...

Current configuration : 127 bytes
vrf definition MANAGEMENT
 !
 address-family ipv4
 exit-address-family
 !
 address-family ipv6
 exit-address-family
!
!
!
end

csr1#conf t
Enter configuration commands, one per line.  End with CNTL/Z.
csr1(config)#vrf definition CORP
csr1(config-vrf)#end
csr1#

csr1#sh run vrf CORP
Building configuration...

Current configuration : 27 bytes
vrf definition CORP
!
end
csr1#

Step 4

Run the playbook pb_vrf.yml again. It will succeed since the CORP VRF now exists. It has also made a change to the VRF's description as per the module parameters!

ntc@ntc-training:lab09$ ansible-playbook pb_vrf.yml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the
implicit localhost does not match 'all'

PLAY [TESTING THE IOSXE_REST_VRF MODULE] ********************************************

TASK [UPDATE DESCRIPTION FOR CORP VRF] **********************************************
changed: [localhost]

PLAY RECAP **************************************************************************
localhost                  : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Step 5

Back on the csr1 CLI, check what's the running-config looking like now. As you can see, the description was successfully added.

csr1#sh run vrf CORP
Building configuration...

Current configuration : 64 bytes
vrf definition CORP
 description VRF FOR CORPORATE USERS
!
end

csr1#

Step 6

Let's make the playbook more dynamic and use an inventory with multiple devices.

Create an inventory file with the following content:

[iosxe]
csr[1:3]

[iosxe:vars]
ansible_user=ntc
ansible_password=ntc123

Step 7

Create a new playbook pb_vrf_descriptions.yml with the following content:

---

- name: TESTING THE IOSXE_REST_VRF MODULE
  hosts: iosxe
  gather_facts: false

  tasks:
    - name: UPDATE DESCRIPTION FOR CORP VRF
      iosxe_rest_vrf:
        host: "{{ inventory_hostname }}"
        user: "{{ ansible_user }}"
        password: "{{ ansible_password }}"
        name: CORP
        description: VRF FOR CORPORATE USERS
      delegate_to: localhost

Important to note that delegate_to: localhost instructs Ansible to run the module locally as otherwise it would attempt to open an SSH connection an execute it remotely on the managed device (the default behavior).

Step 8

Run the playbook pb_vrf_descriptions.yml. It's failing as expected on csr2 and csr3 since they are missing the CORP VRF. But you are now using your inventory data instead of hardcoding connection details inside of the playbook.

ntc@ntc-training:lab09$ ansible-playbook -i inventory pb_vrf_descriptions.yml

PLAY [TESTING THE IOSXE_REST_VRF MODULE] ********************************************

TASK [UPDATE DESCRIPTION FOR CORP VRF] **********************************************
fatal: [csr2 -> localhost]: FAILED! => {
    "changed": false,
    "json": {
        "errors": {
            "error": [
                {
                    "error-message": "patch to a nonexistent resource",
                    "error-path": "/Cisco-IOS-XE-native:native/vrf/definition=CORP",
                    "error-tag": "invalid-value",
                    "error-type": "application"
                }
            ]
        }
    },
    "sent_payload": {
        "Cisco-IOS-XE-native:definition": {
            "description": "VRF FOR CORPORATE USERS",
            "name": "CORP"
        }
    }
}

MSG:

API request failed with code 404
fatal: [csr3 -> localhost]: FAILED! => {
    "changed": false,
    "json": {
        "errors": {
            "error": [
                {
                    "error-message": "patch to a nonexistent resource",
                    "error-path": "/Cisco-IOS-XE-native:native/vrf/definition=CORP",
                    "error-tag": "invalid-value",
                    "error-type": "application"
                }
            ]
        }
    },
    "sent_payload": {
        "Cisco-IOS-XE-native:definition": {
            "description": "VRF FOR CORPORATE USERS",
            "name": "CORP"
        }
    }
}

MSG:

API request failed with code 404
changed: [csr1 -> localhost]

PLAY RECAP **************************************************************************
csr1                       : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
csr2                       : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
csr3                       : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

Note: as expected, it fails on csr2 and csr3 because the VRF does not exist on those routers. At this point, the module does not know how to create a VRF, just how to change a description.

Step 9

Feel free to experiment at this stage with different descriptions and add the VRF to the other CSRs so the playbook succeeds!