NAPALM, Ansible, and Cisco IOS

By Kirk Byers
2016-01-12

David Barroso and Elisa Jasinska recently created a library called NAPALM (Network Automation and Programmability Abstraction Layer with Multivendor support). The general idea behind this library is to create a standardized, multivendor interface for certain file and get operations. Last fall, Gabriele Gerbino added Cisco IOS support to NAPALM.

Independent of NAPALM, I have been thinking about and experimenting with programmatic file operations using Cisco IOS. I wrote a proof of concept related to this here. Consequently, I thought it made sense to add/improve the Cisco IOS file operations in NAPALM. Because of this, I re-wrote the file methods in the NAPALM Cisco IOS driver (sorry about that Gabriele). Basically, I thought using secure copy (SCP), 'configure replace', and a file-based configuration merge (copy <file> system:running-config) would be more consistent with NAPALM and ultimately provide a better solution (in the context of NAPALM file operations).


So what can you do with NAPALM in the context of Cisco IOS?

  • Configuration replace: replace the entire running-config with a completely new configuration.
  • Configuration merge: merge a set of changes from a file into the running-config.
  • Configuration compare: compare your new proposed configuration file with the running-config. This only applies to configuration replace operations; it does not apply to merge operations.
  • Commit: deploy the staged configuration. This can be either an entire new file (for replace operations) or a merge file.
  • Discard: revert the candidate configuration file back to the current running-config; reset the merge configuration file back to an empty file.
  • Rollback: revert the running configuration back to a file that was saved prior to the previous commit.


But what happens under the hood?

Under the hood, there are three files that can potentially be used: 'candidate_config.txt', 'merge_config.txt', and 'rollback_config.txt'. The default file system is 'flash:', but this default can be overridden.

For configure replace operations, the new config file will be secure copied to 'candidate_config.txt'. Upon commit, Cisco's 'configure replace' command will be executed (see here for details).

Similarly, a merge operation will SCP a file to 'flash:/merge_config.txt'. Upon commit, a 'copy flash:merge_config.txt system:running-config' command will be executed (once again a different file system can be used and 'flash:' is not required).

Compare configuration will perform a diff between the candidate_config.txt file and the running-config using 'show archive config differences'.

Finally, the discard and rollback commands will manipulate the three files ('candidate_config.txt', 'merge_config.txt', 'rollback_config.txt'). Discard config will cause the current running-config file to be copied into candidate_config.txt. Additionally, discard config will cause the merge_config.txt file to be zeroed out. The rollback command will cause the 'rollback_config.txt' file to become the running-config (once again using Cisco's 'configure replace' command). Note, the current running-config is saved to rollback_config.txt on any commit operation.


Let's look at an example.

Note, this example will use Ansible to execute the underlying NAPALM code. Ansible is not required, you could also execute NAPALM directly from Python.

Here I have an Ansible playbook (another term for an Ansible script). This script is written in YAML.

---

- name: Test NAPALM on IOS
  hosts: cisco

  tasks:
    - name: Gen .diff file (discard change)
      napalm_install_config: 
            hostname: "{{ host }}"
            username: "{{ username }}"
            password: "{{ password }}"
            dev_os: "{{ dev_os }}"
            config_file: initial.conf
            commit_changes: False
            replace_config: True
            diff_file: initial.diff 

I am going to skip a lot of the Ansible details, but the above Ansible script specifies what to do (a module to execute) and which network devices to execute the module against (the hosts). In this case, the module is 'napalm_install_config' and the hosts is a group named 'cisco'.

Now the details about the 'cisco' group are specified in another file (known as the Ansible inventory file). In this case the 'cisco' group specifies only a single device, 'pynet-rtr1' which is a Cisco 881 router.

All of the lines beneath 'napalm_install_config:' are arguments that get passed into the module. Let's look at these arguments:

            hostname: "{{ host }}"
            username: "{{ username }}"
            password: "{{ password }}"
            dev_os: "{{ dev_os }}"
            config_file: initial.conf
            commit_changes: False
            replace_config: True
            diff_file: initial.diff 

The first four lines specify the hostname (which is an ip address), the username, the password, and the dev_os (in this case 'ios'). The double curly braces indicate an Ansible variable. So where are these four variables defined—they are defined in the Ansible inventory file.

The last four arguments specify additional details about what we are going to do. First, we are going to use a config_file named initial.conf (in the current directory). We are performing a replace_config operation (instead of a merge). We will not execute commit and we will create a diff_file named 'initial.diff'.

So what happens when I execute this script:

$ ansible-playbook napalm_ios.yml -i ./ansible-hosts --module-path ~/napalm_fork/ansible/

PLAY [Test NAPALM on IOS] ***************************************************** 

GATHERING FACTS *************************************************************** 
ok: [pynet-rtr1]

TASK: [Gen .diff file (discard change)] *************************************** 
changed: [pynet-rtr1]

PLAY RECAP ******************************************************************** 
pynet-rtr1                 : ok=2    changed=1    unreachable=0    failed=0  

Here I use the 'ansible-playbook' command. I specify my playbook which is named 'napalm_ios.yml'. I also use the '-i' option to specify my Ansible inventory file, and I use the '--module-path' argument to specify where the 'napalm_install_config' module can be found.

What does this command do? First, it establishes an SSH session from the Ansible control machine to pynet-rtr1. Second it secure copies the 'initial.conf' file to flash:candidate_config.txt. Third, it performs a diff between the current running-config file and the new candidate config and writes the 'initial.diff' file. Finally, since there is no commit, it discards the change and resets candidate_config.txt back to the current running-config.

So what does initial.diff now contain:

$ cat initial.diff 
+logging buffered 20000
-logging buffered 9999 

Consequently, when I commit this change, the logging buffer will be increased to 20000. Let's try this...here is my new playbook (it is indentical to the earlier one except commit_changes is set to True):

---

- name: Test NAPALM on IOS
  hosts: cisco

  tasks:
    - name: Gen .diff file (discard change)
      napalm_install_config:
            hostname: "{{ host }}"
            username: "{{ username }}"
            password: "{{ password }}"
            dev_os: "{{ dev_os }}"
            config_file: initial.conf
            commit_changes: True
            replace_config: True
            diff_file: initial.diff 

I then execute the playbook again:

$ ansible-playbook napalm_ios.yml -i ./ansible-hosts --module-path ~/napalm_fork/ansible/

PLAY [Test NAPALM on IOS] ***************************************************** 

GATHERING FACTS *************************************************************** 
ok: [pynet-rtr1]

TASK: [Gen .diff file (discard change)] *************************************** 
changed: [pynet-rtr1]

PLAY RECAP ******************************************************************** 
pynet-rtr1                 : ok=2    changed=1    unreachable=0    failed=0  

Note, I accidentally left the 'name' field of the task unchanged. The name field is just a text description which in this case says 'Gen .diff file (discard change)'. This description is now misleading because we are committing the change.

Here is a before and after on the router:

pynet-rtr1#show run | inc logging       # before
logging buffered 9999
no logging console 
pynet-rtr1#show run | inc logging       # after
logging buffered 20000
no logging console 

Also looking at the files on router's filesystem:

pynet-rtr1#dir flash:candidate_config.txt
Directory of flash:/candidate_config.txt

   31  -rw-        5612  Jan 12 2016 12:02:54 -08:00  candidate_config.txt

128843776 bytes total (52367360 bytes free)

pynet-rtr1#verify /md5 flash:candidate_config.txt
..MD5 of flash:candidate_config.txt Done!
verify /md5 (flash:candidate_config.txt) = 4aae0a9586139ed19a870c96768ecab9 

This MD5 is identical to the MD5 of the initial.conf file.

I also have a copy of the previous running-config (i.e. before the commit):

pynet-rtr1#dir flash:rollback_config.txt
Directory of flash:/rollback_config.txt

   33  -rw-        5631  Jan 12 2016 12:03:44 -08:00  rollback_config.txt

128843776 bytes total (52367360 bytes free)

pynet-rtr1#more flash:rollback_config.txt | inc logging
logging buffered 9999
no logging console
pynet-rtr1# 

Okay, that was a pretty boring change—what about a change that does a little more.

Let's configure EIGRP on two routers. First, I added another router to my Ansible inventory file (a router named pynet-test). My Ansible inventory file now looks like this (with some of the values modified to keep them private):

[local]
localhost ansible_connection=local

[cisco]
pynet-rtr1 port=22 
pynet-test port=9622

[cisco:vars]
host=1.1.1.27
username=admin
password=password
ansible_connection=local
dev_os=ios 

Note, I am testing from AWS so the two routers are sharing a public IP address and there is a firewall in front of them doing a port address translation.

I now use a new playbook:

---

- name: Test NAPALM on IOS (r1)
  hosts: pynet-rtr1
  tasks:
    - name: Install eigrp on pynet-rtr1
      napalm_install_config:
            hostname: "{{ host }}"
            username: "{{ username }}"
            password: "{{ password }}"
            dev_os: "{{ dev_os }}"
            config_file: eigrp_r1.conf
            commit_changes: True
            replace_config: True
            diff_file: eigrp_r1.diff

- name: Test NAPALM on IOS (rtest)
  hosts: pynet-test
  tasks:
    - name: Install eigrp on pynet-test
      napalm_install_config:
            hostname: "{{ host }}"
            username: "{{ username }}"
            password: "{{ password }}"
            dev_os: "{{ dev_os }}"
            config_file: eigrp_rtest.conf
            commit_changes: True
            replace_config: True
            diff_file: eigrp_rtest.diff
            optional_args: {'port': "", 'auto_rollback_on_error': False} 

The above playbook (Ansible script) has two different parts (which Ansible terms 'plays'). The first 'play' causes a new file 'eigrp_r1.conf' to be loaded onto pynet-rtr1; the second 'play' causes 'eigrp_rtest.conf' to be loaded onto the pynet-test router. The 'optional_args' argument is necessary in order to pass the non-standard SSH port. Additional, the pynet-test router is a bit old so I had to add the auto_rollback_on_error argument and set this to False. This router doesn't support automatic rollback upon error (at the Cisco IOS level).

You can see that currently neither router is running EIGRP:

pynet-rtr1#show run | inc router eigrp
pynet-rtr1# 
pynet-test#show run | inc router eigrp
pynet-test# 

First, I run this with commit_changes set to False and look at the two diff files (to see what is going to change):

$ cat eigrp_r1.diff 
+router eigrp 10
 +network 10.220.88.0 0.0.0.255
 +no auto-summary 
$ cat eigrp_rtest.diff 
+router eigrp 10
 +network 10.220.88.0 0.0.0.255
 +no auto-summary 

Next, I deploy this config (i.e. set commit_changes back to True).

$ ansible-playbook eigrp_config.yml -i ./ansible-hosts --module-path ~/napalm_fork/ansible/

PLAY [Test NAPALM on IOS (r1)] ************************************************ 

GATHERING FACTS *************************************************************** 
ok: [pynet-rtr1]

TASK: [Install eigrp on pynet-rtr1] ******************************************* 
changed: [pynet-rtr1]

PLAY [Test NAPALM on IOS (rtest)] ********************************************* 

GATHERING FACTS *************************************************************** 
ok: [pynet-test]

TASK: [Install eigrp on pynet-test] ******************************************* 
changed: [pynet-test]

PLAY RECAP ******************************************************************** 
pynet-rtr1                 : ok=2    changed=1    unreachable=0    failed=0   
pynet-test                 : ok=2    changed=1    unreachable=0    failed=0   

Now if it all went well I should have a running EIGRP configuration.

pynet-rtr1#show run | section router eigrp
router eigrp 10
 network 10.220.88.0 0.0.0.255

pynet-rtr1#show ip eigrp neighbors 
EIGRP-IPv4 Neighbors for AS(10)
H   Address                 Interface              Hold Uptime   SRTT   RTO  Q  Seq
                                                   (sec)         (ms)       Cnt Num
0   10.220.88.22            Fa4                      10 00:01:59  792  4752  0  3 
pynet-test#show run | section router eigrp
router eigrp 10
 network 10.220.88.0 0.0.0.255
 no auto-summary

pynet-test#show ip eigrp neighbors 
IP-EIGRP neighbors for process 10
H   Address                 Interface       Hold Uptime   SRTT   RTO  Q  Seq
                                            (sec)         (ms)       Cnt Num
0   10.220.88.20            Fa4               13 00:02:15  202  1212  0  1 

Boom, there it is :-)

Hopefully, I will cover merge operations in more detail in a future article.


If you want to learn more about network automation, Python, and Ansible—then join my email-list. I also periodically run a free Python for Network Engineers email course which you can sign-up for here.

router image

Kirk Byers
CCIE #6243 emeritus
Twitter: @kirkbyers

NAPALM, Ansible, and Cisco IOS

By Kirk Byers
2016-01-12

David Barroso and Elisa Jasinska recently created a library called NAPALM (Network Automation and Programmability Abstraction Layer with Multivendor support). The general idea behind this library is to create a standardized, multivendor interface for certain file and get operations. Last fall, Gabriele Gerbino added Cisco IOS support to NAPALM.

Independent of NAPALM, I have been thinking about and experimenting with programmatic file operations using Cisco IOS. I wrote a proof of concept related to this here. Consequently, I thought it made sense to add/improve the Cisco IOS file operations in NAPALM. Because of this, I re-wrote the file methods in the NAPALM Cisco IOS driver (sorry about that Gabriele). Basically, I thought using secure copy (SCP), 'configure replace', and a file-based configuration merge (copy <file> system:running-config) would be more consistent with NAPALM and ultimately provide a better solution (in the context of NAPALM file operations).


So what can you do with NAPALM in the context of Cisco IOS?

  • Configuration replace: replace the entire running-config with a completely new configuration.
  • Configuration merge: merge a set of changes from a file into the running-config.
  • Configuration compare: compare your new proposed configuration file with the running-config. This only applies to configuration replace operations; it does not apply to merge operations.
  • Commit: deploy the staged configuration. This can be either an entire new file (for replace operations) or a merge file.
  • Discard: revert the candidate configuration file back to the current running-config; reset the merge configuration file back to an empty file.
  • Rollback: revert the running configuration back to a file that was saved prior to the previous commit.


But what happens under the hood?

Under the hood, there are three files that can potentially be used: 'candidate_config.txt', 'merge_config.txt', and 'rollback_config.txt'. The default file system is 'flash:', but this default can be overridden.

For configure replace operations, the new config file will be secure copied to 'candidate_config.txt'. Upon commit, Cisco's 'configure replace' command will be executed (see here for details).

Similarly, a merge operation will SCP a file to 'flash:/merge_config.txt'. Upon commit, a 'copy flash:merge_config.txt system:running-config' command will be executed (once again a different file system can be used and 'flash:' is not required).

Compare configuration will perform a diff between the candidate_config.txt file and the running-config using 'show archive config differences'.

Finally, the discard and rollback commands will manipulate the three files ('candidate_config.txt', 'merge_config.txt', 'rollback_config.txt'). Discard config will cause the current running-config file to be copied into candidate_config.txt. Additionally, discard config will cause the merge_config.txt file to be zeroed out. The rollback command will cause the 'rollback_config.txt' file to become the running-config (once again using Cisco's 'configure replace' command). Note, the current running-config is saved to rollback_config.txt on any commit operation.


Let's look at an example.

Note, this example will use Ansible to execute the underlying NAPALM code. Ansible is not required, you could also execute NAPALM directly from Python.

Here I have an Ansible playbook (another term for an Ansible script). This script is written in YAML.

---

- name: Test NAPALM on IOS
  hosts: cisco

  tasks:
    - name: Gen .diff file (discard change)
      napalm_install_config: 
            hostname: "{{ host }}"
            username: "{{ username }}"
            password: "{{ password }}"
            dev_os: "{{ dev_os }}"
            config_file: initial.conf
            commit_changes: False
            replace_config: True
            diff_file: initial.diff 

I am going to skip a lot of the Ansible details, but the above Ansible script specifies what to do (a module to execute) and which network devices to execute the module against (the hosts). In this case, the module is 'napalm_install_config' and the hosts is a group named 'cisco'.

Now the details about the 'cisco' group are specified in another file (known as the Ansible inventory file). In this case the 'cisco' group specifies only a single device, 'pynet-rtr1' which is a Cisco 881 router.

All of the lines beneath 'napalm_install_config:' are arguments that get passed into the module. Let's look at these arguments:

            hostname: "{{ host }}"
            username: "{{ username }}"
            password: "{{ password }}"
            dev_os: "{{ dev_os }}"
            config_file: initial.conf
            commit_changes: False
            replace_config: True
            diff_file: initial.diff 

The first four lines specify the hostname (which is an ip address), the username, the password, and the dev_os (in this case 'ios'). The double curly braces indicate an Ansible variable. So where are these four variables defined—they are defined in the Ansible inventory file.

The last four arguments specify additional details about what we are going to do. First, we are going to use a config_file named initial.conf (in the current directory). We are performing a replace_config operation (instead of a merge). We will not execute commit and we will create a diff_file named 'initial.diff'.

So what happens when I execute this script:

$ ansible-playbook napalm_ios.yml -i ./ansible-hosts --module-path ~/napalm_fork/ansible/

PLAY [Test NAPALM on IOS] ***************************************************** 

GATHERING FACTS *************************************************************** 
ok: [pynet-rtr1]

TASK: [Gen .diff file (discard change)] *************************************** 
changed: [pynet-rtr1]

PLAY RECAP ******************************************************************** 
pynet-rtr1                 : ok=2    changed=1    unreachable=0    failed=0  

Here I use the 'ansible-playbook' command. I specify my playbook which is named 'napalm_ios.yml'. I also use the '-i' option to specify my Ansible inventory file, and I use the '--module-path' argument to specify where the 'napalm_install_config' module can be found.

What does this command do? First, it establishes an SSH session from the Ansible control machine to pynet-rtr1. Second it secure copies the 'initial.conf' file to flash:candidate_config.txt. Third, it performs a diff between the current running-config file and the new candidate config and writes the 'initial.diff' file. Finally, since there is no commit, it discards the change and resets candidate_config.txt back to the current running-config.

So what does initial.diff now contain:

$ cat initial.diff 
+logging buffered 20000
-logging buffered 9999 

Consequently, when I commit this change, the logging buffer will be increased to 20000. Let's try this...here is my new playbook (it is indentical to the earlier one except commit_changes is set to True):

---

- name: Test NAPALM on IOS
  hosts: cisco

  tasks:
    - name: Gen .diff file (discard change)
      napalm_install_config:
            hostname: "{{ host }}"
            username: "{{ username }}"
            password: "{{ password }}"
            dev_os: "{{ dev_os }}"
            config_file: initial.conf
            commit_changes: True
            replace_config: True
            diff_file: initial.diff 

I then execute the playbook again:

$ ansible-playbook napalm_ios.yml -i ./ansible-hosts --module-path ~/napalm_fork/ansible/

PLAY [Test NAPALM on IOS] ***************************************************** 

GATHERING FACTS *************************************************************** 
ok: [pynet-rtr1]

TASK: [Gen .diff file (discard change)] *************************************** 
changed: [pynet-rtr1]

PLAY RECAP ******************************************************************** 
pynet-rtr1                 : ok=2    changed=1    unreachable=0    failed=0  

Note, I accidentally left the 'name' field of the task unchanged. The name field is just a text description which in this case says 'Gen .diff file (discard change)'. This description is now misleading because we are committing the change.

Here is a before and after on the router:

pynet-rtr1#show run | inc logging       # before
logging buffered 9999
no logging console 
pynet-rtr1#show run | inc logging       # after
logging buffered 20000
no logging console 

Also looking at the files on router's filesystem:

pynet-rtr1#dir flash:candidate_config.txt
Directory of flash:/candidate_config.txt

   31  -rw-        5612  Jan 12 2016 12:02:54 -08:00  candidate_config.txt

128843776 bytes total (52367360 bytes free)

pynet-rtr1#verify /md5 flash:candidate_config.txt
..MD5 of flash:candidate_config.txt Done!
verify /md5 (flash:candidate_config.txt) = 4aae0a9586139ed19a870c96768ecab9 

This MD5 is identical to the MD5 of the initial.conf file.

I also have a copy of the previous running-config (i.e. before the commit):

pynet-rtr1#dir flash:rollback_config.txt
Directory of flash:/rollback_config.txt

   33  -rw-        5631  Jan 12 2016 12:03:44 -08:00  rollback_config.txt

128843776 bytes total (52367360 bytes free)

pynet-rtr1#more flash:rollback_config.txt | inc logging
logging buffered 9999
no logging console
pynet-rtr1# 

Okay, that was a pretty boring change—what about a change that does a little more.

Let's configure EIGRP on two routers. First, I added another router to my Ansible inventory file (a router named pynet-test). My Ansible inventory file now looks like this (with some of the values modified to keep them private):

[local]
localhost ansible_connection=local

[cisco]
pynet-rtr1 port=22 
pynet-test port=9622

[cisco:vars]
host=1.1.1.27
username=admin
password=password
ansible_connection=local
dev_os=ios 

Note, I am testing from AWS so the two routers are sharing a public IP address and there is a firewall in front of them doing a port address translation.

I now use a new playbook:

---

- name: Test NAPALM on IOS (r1)
  hosts: pynet-rtr1
  tasks:
    - name: Install eigrp on pynet-rtr1
      napalm_install_config:
            hostname: "{{ host }}"
            username: "{{ username }}"
            password: "{{ password }}"
            dev_os: "{{ dev_os }}"
            config_file: eigrp_r1.conf
            commit_changes: True
            replace_config: True
            diff_file: eigrp_r1.diff

- name: Test NAPALM on IOS (rtest)
  hosts: pynet-test
  tasks:
    - name: Install eigrp on pynet-test
      napalm_install_config:
            hostname: "{{ host }}"
            username: "{{ username }}"
            password: "{{ password }}"
            dev_os: "{{ dev_os }}"
            config_file: eigrp_rtest.conf
            commit_changes: True
            replace_config: True
            diff_file: eigrp_rtest.diff
            optional_args: {'port': "", 'auto_rollback_on_error': False} 

The above playbook (Ansible script) has two different parts (which Ansible terms 'plays'). The first 'play' causes a new file 'eigrp_r1.conf' to be loaded onto pynet-rtr1; the second 'play' causes 'eigrp_rtest.conf' to be loaded onto the pynet-test router. The 'optional_args' argument is necessary in order to pass the non-standard SSH port. Additional, the pynet-test router is a bit old so I had to add the auto_rollback_on_error argument and set this to False. This router doesn't support automatic rollback upon error (at the Cisco IOS level).

You can see that currently neither router is running EIGRP:

pynet-rtr1#show run | inc router eigrp
pynet-rtr1# 
pynet-test#show run | inc router eigrp
pynet-test# 

First, I run this with commit_changes set to False and look at the two diff files (to see what is going to change):

$ cat eigrp_r1.diff 
+router eigrp 10
 +network 10.220.88.0 0.0.0.255
 +no auto-summary 
$ cat eigrp_rtest.diff 
+router eigrp 10
 +network 10.220.88.0 0.0.0.255
 +no auto-summary 

Next, I deploy this config (i.e. set commit_changes back to True).

$ ansible-playbook eigrp_config.yml -i ./ansible-hosts --module-path ~/napalm_fork/ansible/

PLAY [Test NAPALM on IOS (r1)] ************************************************ 

GATHERING FACTS *************************************************************** 
ok: [pynet-rtr1]

TASK: [Install eigrp on pynet-rtr1] ******************************************* 
changed: [pynet-rtr1]

PLAY [Test NAPALM on IOS (rtest)] ********************************************* 

GATHERING FACTS *************************************************************** 
ok: [pynet-test]

TASK: [Install eigrp on pynet-test] ******************************************* 
changed: [pynet-test]

PLAY RECAP ******************************************************************** 
pynet-rtr1                 : ok=2    changed=1    unreachable=0    failed=0   
pynet-test                 : ok=2    changed=1    unreachable=0    failed=0   

Now if it all went well I should have a running EIGRP configuration.

pynet-rtr1#show run | section router eigrp
router eigrp 10
 network 10.220.88.0 0.0.0.255

pynet-rtr1#show ip eigrp neighbors 
EIGRP-IPv4 Neighbors for AS(10)
H   Address                 Interface              Hold Uptime   SRTT   RTO  Q  Seq
                                                   (sec)         (ms)       Cnt Num
0   10.220.88.22            Fa4                      10 00:01:59  792  4752  0  3 
pynet-test#show run | section router eigrp
router eigrp 10
 network 10.220.88.0 0.0.0.255
 no auto-summary

pynet-test#show ip eigrp neighbors 
IP-EIGRP neighbors for process 10
H   Address                 Interface       Hold Uptime   SRTT   RTO  Q  Seq
                                            (sec)         (ms)       Cnt Num
0   10.220.88.20            Fa4               13 00:02:15  202  1212  0  1 

Boom, there it is :-)

Hopefully, I will cover merge operations in more detail in a future article.


If you want to learn more about network automation, Python, and Ansible—then join my email-list. I also periodically run a free Python for Network Engineers email course which you can sign-up for here.

router image

Kirk Byers
CCIE #6243 emeritus
Twitter: @kirkbyers