Network automation using Ansible and NETCONF

One way you can do simple network automation is to use Ansible. Ansible uses playbooks which contain several tasks. Tasks are basically individual actions to take to produce a desired outcome. For network automation this could be retrieving configuration of a device, writing it to a file, then uploading it to a server for backup storage. You can also use Ansible to configure network devices as well. Ansible can interact with network devices in many ways such as using SSH to access the traditional command line or using more advanced methods such as NETCONF or RESTCONF. In this post I will be demonstrating a very simple use of Ansible using NETCONF with Cisco routes to retrieve configuration and push configuration to multiple devices.

Before we jump into playbooks and configuration let’s take a look at the topology we will be using for this demonstration. This is a very simplified topology configured to be similar to an MPLS service provider with two PE routers to which a customer router is connected. The PE routers are then connected together by a P router which is also acting a BGP route reflector in this scenario. The goal of this demonstration is to deploy an MPLS VPN that will allow the customer routers to be able to reach each other. Below is an image of the topology.

Network Topology sample for automation with netconf and ansible

Ansible Setup

First we will take a brief look at getting Ansible setup. This post is not intended to be a fully in depth guide to Ansible so we will only briefly touch on this. Ansible needs 3 main components to run. An ansible.cfg file, an inventory file and a playbook. The ansible.cfg file contains configuration for ansible its self, the inventory file contains a list of devices that Ansible will configure, and the playbook will contain the instructions for deploying the configuration. For this demonstration I will be using a very simple Ansible set up. I have created a virtual environment in which I have installed nccleint and ansible via pip. For newer versions of ansible you may also need to install the netcommon module from ansible galaxy for netconf to be available. See below for the commands used to install these items.

python3 -m venv ./cisco-config
cd cisco-config
source ./bin/activate
pip install ncclient
pip install ansible
ansible-galaxy collection install ansible.netcommon

Below is the content of the ansible.cfg I will be using. I will be telling ansible which file contains the inventory, disable host checking so that it will automatically accept SSH keys when connecting to a device for the first time, and disabling information gathering as this is not needed for this scenario. I have also increased the connect and command timeouts which may or may not be needed depending on your environment.

[defaults]
inventory = ./hosts
host_key_checking = false
gathering = explicit

[persistent_connection]
connect_timeout = 300
command_timeout = 300

Below is the inventory file simply named ‘hosts’. This file begins by creating a group called MPLS-SERVICE then each line lists a host name and an IP address that ansible should connect to to reach the host. This file also includes some variables related to the group of devices. In this case we are just storing the username and password, however more variables can go here and you can also create arbitrary ones yourself. Note that it is not a good idea to store device credentials in this way, but this is a simple way to do it for a demonstration. There are also more advanced methods for storing group and device specific variables, however we are keeping it simple this time.

[MPLS-SERVICE]
PE1 ansible_host=10.0.0.1
PE2 ansible_host=10.0.0.3

[MPLS-SERVICE:vars]
ansible_user=cisco
ansible_password=cisco

As for the third component, the playbooks, we will be looking at those throughout the following sections.

Retrieving Configuration

First let’s look at a playbook. In this example we are simply pulling configuration. This is simply a file in yaml format named get-interface-config.yaml. See below for the contents of the file.

---
    - hosts: MPLS-SERVICE
      connection: netconf
      tasks:
        - name: Get Interface Config
          netconf_get:
            source: running
            filter: |
                <native xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-native">
                       <interface>
                        <GigabitEthernet>
                         <name>1</name>
                        </GigabitEthernet>
                       </interface>
                      </native>
          register: config
        -  name: Write Config To File
           copy: 
            content: "{{ config['stdout'] }}" 
            dest: ./configs/{{ inventory_hostname }}-Interface-config.xml

It beings by telling Ansible which Hosts need to be configured by calling out the group in the ‘hosts’ field. We then tell Ansible it should be using ‘netconf’ as the connection method. The remaining configuration are the tasks themselves. The first task is named ‘Get Interface Config’. This task will connect to each device in the ‘MPLS-SERVICE’ group and use a netconf get operation to retrieve the configuration from the running configuration as configured with the ‘source’ field. The filter field is not required, however this significantly reduces the output returned from the operation. If you want to retrieve the entire configuration from the device, you can leave this part out. However for the demonstration we will limit this to just the configuration of interface GigabitEthernet1. It will then store the retreived information in a variable named ‘config’ which is set using the ‘register’ field.

The second task in this play book will write the configuration to a file. The ‘content’ field takes the variable created in the first task and writes it to a file as specified in the ‘dest’ field. Since ‘config’ variable is a dictionary we we can access just the portion we are interested in, which happens to be contained within the key ‘stdout’. This will create a file for each device in our inventory list and place it in the configs directory. To run the playbook you will use the command ‘ansible-playbook get-interface-config.yaml’. The below output will show that the playbook as ran successfully.

(blog-ansible) Mac-mini:src seth$ ansible-playbook get-interface-config.yaml

PLAY [MPLS-SERVICE] ************************************************************

TASK [Get Config] **************************************************************
ok: [PE2]
ok: [PE1]

TASK [Write Config To File] ****************************************************
changed: [PE1]
changed: [PE2]

PLAY RECAP *********************************************************************
PE1                : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
PE2                : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

As indicated in the playbook, this will produce a configuration file in the configs directory for each host in the MPLS-SERVICE group in our inventory file. In this case, that will be only two files. See below for the contents of the configuration file for PE1.

(blog-ansible) Mac-mini:src seth$ ls
PE1-Interface-config.xml  PE2-Interface-config.xml


(blog-ansible) Mac-mini:src seth$ xmllint --format PE1-Interface-config.xml
<?xml version="1.0"?>
<data xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">
  <native xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-native">
    <interface>
      <GigabitEthernet>
        <name>1</name>
        <description>MPLS-BACKBONE-LINK</description>
        <mpls>
          <ip xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-mpls"/>
        </mpls>
        <ip>
          <address>
            <primary>
              <address>150.1.1.1</address>
              <mask>255.255.255.252</mask>
            </primary>
          </address>
          <router-ospf xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-ospf">
            <ospf>
              <process-id>
                <id>1</id>
                <area>
                  <area-id>0</area-id>
                </area>
              </process-id>
            </ospf>
          </router-ospf>
        </ip>
        <logging>
          <event>
            <link-status/>
          </event>
        </logging>
        <mop>
          <enabled>false</enabled>
          <sysid>false</sysid>
        </mop>
        <negotiation xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-ethernet">
          <auto>true</auto>
        </negotiation>
      </GigabitEthernet>
    </interface>
  </native>
</data>

This output is an XML document describing the configuration of GigabitEthernet1 on router PE1. You can see information such as the interface description, the IP address on the interface, and that it is configured to run OSPF.

Pushing Configuration

Now let’s look at how we would push configuration to the routers. Let’s say we have a new customer SethCo that will be connecting to our MPLS routers PE1 and PE2. We need to configure the interface in which they are connected and set up the VRF for this customer. First, let’s take a look at the interface and confirm that it is currently not configured. We will use the same playbook as we used previously, however we will change the filter to look for GigabitEthernet3 instead. Below we can see the interfaces are not configured.

<interface>
    <GigabitEthernet>
        <name xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">3</name>
        <shutdown/>
        <mop>
            <enabled>false</enabled>
            <sysid>false</sysid>
        </mop>
        <negotiation xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-ethernet">
            <auto>true</auto>
        </negotiation>
</interface>
    </GigabitEthernet>

The first thing we need to do is prepare the configuration for deploying the customer. Below is a classic command line example of the configuration we will be using.

vrf definition SethCo
 rd 25:100
 !
 address-family ipv4
  route-target export 100:100
  route-target import 100:100
 exit-address-family
!
!
interface GigabitEthernet3
 vrf forwarding SethCo
 ip address 10.0.0.1 255.255.255.0
 negotiation auto
 no mop enabled
 no mop sysid
!
router bgp 1
 !
 address-family ipv4 vrf SethCo
  network 10.20.1.0 mask 255.255.255.0
 exit-address-family
!
end

Since we are using netconf to manage the configuration of our devices, we need to convert this to YANG format. I wont go over how to create the YANG style configuration, but you may start with another post of mine Interpreting YANG for Network Automation with NETCONF if you are unfamiliar with it. Below is the configuration we will actually be sending to the device. This will be saved to a file called ‘pe-config.xml’ in the same directory as the playbook.

<config>
    <native xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-native">
        <vrf>
            <definition>
                <name>SethCo</name>
                <rd>25:100</rd>
                <address-family>
                    <ipv4>
                        <route-target>
                            <export>
                                <asn-ip>100:100</asn-ip>
                            </export>
                            <import>
                                <asn-ip>100:100</asn-ip>
                            </import>
                        </route-target>
                    </ipv4>
                </address-family>
            </definition>
        </vrf>
        <interface>
            <GigabitEthernet>
                <name>3</name>
                <shutdown operation="delete"/>
                <vrf>
                    <forwarding>SethCo</forwarding>
                </vrf>
                <ip>
                    <address>
                        <primary>
                            <address>10.0.0.1</address>
                            <mask>255.255.255.0</mask>
                        </primary>
                    </address>
                </ip>
                <mop>
                    <enabled>false</enabled>
                    <sysid>false</sysid>
                </mop>
                <negotiation xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-ethernet">
                    <auto>true</auto>
                </negotiation>
            </GigabitEthernet>
        </interface>
        <router>
            <bgp xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-bgp">
                <id>1</id>
                <address-family>
                    <with-vrf>
                        <ipv4>
                            <af-name>unicast</af-name>
                            <vrf>
                                <name>SethCo</name>
                                <ipv4-unicast>
                                    <network>
                                        <with-mask>
                                            <number>10.20.1.0</number>
                                            <mask>255.255.255.0</mask>
                                        </with-mask>
                                    </network>
                                </ipv4-unicast>
                            </vrf>
                        </ipv4>
                    </with-vrf>
                </address-family>
            </bgp>
        </router>
    </native>
</config>

Now that we have our configuration, we need to create a playbook to apply it. In this playbook we will be using the module netconf_config to push configuration to the devices. We need to target the condidate configuration and if successful we want the playbook the commit the configuration. I am also applying the lock option to lock the configuration until the netconf operation completes. This will help prevent a collision if two people are trying to make a change at the same time. And finally we use the content option to reference the file we created that contains our configuration. Below is the playbook we will be using.

---
  - hosts: MPLS-SERVICE
    connection: netconf
    tasks:
      - name: Deploy Customer Configuration
        netconf_config:
         target: candidate
         commit: yes
         lock: always
         content: "{{ lookup('file', './pe-config.xml') }}"

Now we can run this playbook to apply the configuration. Below are the results of the playbook.

(blog-ansible) Mac-mini:src seth$ ansible-playbook apply-config.yaml 


PLAY [MPLS-SERVICE] **************************************************************************************************************************

TASK [Deploy Customer Configuration] *********************************************************************************************************
changed: [PE2]
changed: [PE1]

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

We can see above that the playbook has reported a change to both PE1 and PE2 as expected. Let’s re-run our playbook to retrieve the configuration and confirm that the changes have been made. I will change the filter in this playbook to only look at GigabitEthernet3 as this is the interface we have configured for the customer. To keep the output brief for this demonstration, I am only looking at the interface to confirm the configuration is applied. You can see the entire configuration (bgp and vrf configuration) as well if you remove the filter from the playbook. Below is configuration retrieved using the playbook.

<data xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"
    xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">
    <native xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-native">
        <interface>
            <GigabitEthernet>
                <name xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">3</name>
                <vrf>
                    <forwarding>SethCo</forwarding>
                </vrf>
                <ip>
                    <address>
                        <primary>
                            <address>10.0.0.1</address>
                            <mask>255.255.255.0</mask>
                        </primary>
                    </address>
                </ip>
                <mop>
                    <enabled>false</enabled>
                    <sysid>false</sysid>
                </mop>
                <negotiation xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-ethernet">
                    <auto>true</auto>
                </negotiation>
            </GigabitEthernet>
        </interface>
    </native>
</data>

Now the interface has been configured for this customer. Compare this output to the interface section of the configuration we had prepared to send to the router. It should look very similar and we can confirm the configuration we wanted to apply is now on the interface. Further, let’s confirm the configuration via CLI. Below is the ‘show run vrf’ of the configuration we applied.

vrf definition SethCo
 rd 25:100
 !
 address-family ipv4
  route-target export 100:100
  route-target import 100:100
 exit-address-family
!
!
interface GigabitEthernet3
 vrf forwarding SethCo
 ip address 10.0.0.1 255.255.255.0
 negotiation auto
!
router bgp 1
 !
 address-family ipv4 vrf SethCo
  network 10.20.1.0 mask 255.255.255.0
 exit-address-family

While this post focused on a very simple use of ansible there are many more advanced methods you can use along side it. In this demonstration we used a simple XML file with static configuration to apply to the router, however you could use a jinja template here to make this configuration much more repeatable. If you want to learn more about jinja, check out my post Creating Network Configurations with Jinja. You may also create a web front end to make this easier to consume for less advanced users. You can learn more about creating a web front end for your network automation tools on my post Creating a web front-end with Flask and Python for Network Automation. If you would like to view any of the files from the example you can find it at this repository on github.

Leave a Reply