Using Ansible to Configure

DHCP Helpers

Author: Kirk Byers
Date: 2017-04-07

The Problem

I need to configure a DHCP helper on a set of VLANs across a set of network devices. I also need to ensure that DHCP helpers are not configured on any other VLANs besides the ones specified.

Additional qualifications—the network devices are brownfield. In other words, I can't just load full configurations programmatically (i.e. skip the verification step because I am generating the entire configuration).

Finally, the VLAN interfaces and helpers can't be the same for all of the devices. In other words, the problem can't be artificially simplified.

The Setup

Ansible Version: 2.2.2.0 (which is the latest released version as of today).

Two Cisco routers that support VLAN interfaces and four Arista vEOS switches.

My Ansible inventory file is as follows (with IP addresses and passwords modified from their real values):

[all:vars]
ansible_connection=local
ansible_python_interpreter=/home/kbyers/VENV/ansible/bin/python

[local]
localhost ansible_connection=local

[cisco]
pynet-rtr1 ansible_host=10.10.10.70
pynet-rtr2 ansible_host=10.10.10.71 

[cisco:vars]
username=invalid
password=bogus

[arista]
pynet-sw5 ansible_host=10.10.10.83
pynet-sw6 ansible_host=10.10.10.84
pynet-sw7 ansible_host=10.10.10.85
pynet-sw8 ansible_host=10.10.10.86

[arista:vars]
username=invalid
password=bogus

Note, since all the devices will be using the same 'ansible_python_interpreter' and also will be using 'ansible_connection=local', I placed these variables in the [all:vars] section.

Now let's say we want the following 'ip helper' addresses to be configured (and only these):

---
pynet-rtr1:
  - Vlan1: 1.1.1.1
  - Vlan20: 1.1.1.1
  - Vlan60: 1.1.1.99

pynet-rtr2:
  - Vlan1: 1.1.1.1
  - Vlan20: 1.1.1.1
  - Vlan40: 1.1.1.254
  - Vlan99: 1.1.1.253

# All the Arista switches
arista:
  - Vlan1: 1.1.1.1
  - Vlan60: 1.1.1.88
  - Vlan99: 1.1.1.253

Configuring IP Helper

We need to configure the above IP helper addresses on the specified VLANs. There are different ways we could accomplish this configuration task. Two that come to mind are NAPALM and the Ansible core module ios_config. Let's try to accomplish this using ios_config

A typical pattern I will use is to solve the problem for one router and then expand the solution to multiple devices. Consequently, let's start out by solving this for just pynet-rtr1.

I know from past experience that ios_config can use a 'provider' for the username, password, and device_ip. I also know that I somehow need to specify the interfaces and helpers that need configured. I will start out by specifying these as a variable directly in the playbook.

Here is my initial playbook:

---
- name: Configure ip-helper on single router
  hosts: pynet-rtr1
  vars:
    creds:
        host: "{{ ansible_host }}"
        username: "{{ username }}"
        password: "{{ password }}"

    dhcp_interfaces:
      - interface: Vlan1
        helper: 1.1.1.1
      - interface: Vlan20
        helper: 1.1.1.1
      - interface: Vlan60
        helper: 1.1.1.99

  tasks:
    - ios_facts:
        provider: "{{ creds }}"

Note, I temporarily switched to the 'ios_facts' module as I want to ensure my playbook, inventory, and provider are all correct. Also, note I chose my 'dhcp_interfaces' data structure such that I can loop over it in Ansible (the value of dhcp_interfaces is a list where each list element is a dictionary).

Let's test this playbook and see if we can connect to the remote device properly:

$ ansible-playbook helper_single_device.yml -i ./ansible-hosts

...omitted...

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

That worked without any issues.

Now let's try to make the IP helper configuration changes.

First, I remove 'ios_facts' and add the 'ios_config' module (everything else stays the same). My 'tasks' section now looks as follows:

tasks:
    - ios_config:
        provider: "{{ creds }}"
        lines:
          - ip helper-address {{ item.helper }}
        parents: interface {{ item.interface }}
      with_items: "{{ dhcp_interfaces }}"

Here I create an Ansible for-loop (with_items) and I loop over all of the list elements specified in the dhcp_interfaces variables. Each time through the loop a temporary variable named 'item' will contain the list element (consequently, item.interface will be the interface and item.helper will be the IP helper address).

Running this script yields the following:

$ ansible-playbook helper_single_device.yml -i ./ansible-hosts

PLAY [Configure ip-helper on single router] ************************************

TASK [setup] *******************************************************************
ok: [pynet-rtr1]

TASK [ios_config] **************************************************************
changed: [pynet-rtr1] => (item={u'interface': u'Vlan1', u'helper': u'1.1.1.1'})
changed: [pynet-rtr1] => (item={u'interface': u'Vlan20', u'helper': u'1.1.1.1'})
changed: [pynet-rtr1] => (item={u'interface': u'Vlan60', u'helper': u'1.1.1.99'})

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

Note, I verified before executing this that pynet-rtr1 had no preexisting IP helpers configured.

My router config now looks as follows:

pynet-rtr1#show run | section interface
...omitted...
interface Vlan1
 no ip address
 ip helper-address 1.1.1.1
interface Vlan20
 no ip address
 ip helper-address 1.1.1.1
interface Vlan60
 no ip address
 ip helper-address 1.1.1.99

Expanding Configuration to Six Devices

Now let's try to generlize this configuration to the second router and to the Arista switches.

The first problem I have is that the 'dhcp_interfaces' variable needs to be different (for the different devices). Consequently, I need these variables to be host or group variables (instead of having the variables directly embedded in the playbook). This makes me want to use Ansible's host_vars and group_vars.

First, I copy 'helper_single_device.yml' to 'helper_config.yml' and then remove the 'dhcp_interfaces' variable. My playbook now looks as follows:

---
- name: Configure ip-helper on single router
  hosts: cisco
  vars:
    creds:
        host: "{{ ansible_host }}"
        username: "{{ username }}"
        password: "{{ password }}"

  tasks:
    - ios_config:
        provider: "{{ creds }}"
        lines:
          - ip helper-address {{ item.helper }}
        parents: interface {{ item.interface }}
      with_items: "{{ dhcp_interfaces }}"

Reminder, the 'cisco' group of my inventory contains both pynet-rtr1 and pynet-rtr2.

Now I need to setup my host_vars directory which I do as follows:

$ tree host_vars/
host_vars/
├── pynet-rtr1
│   └── dhcp.yml
└── pynet-rtr2
    └── dhcp.yml

2 directories, 2 files 
$ cat host_vars/pynet-rtr1/dhcp.yml 
---
dhcp_interfaces:
  - interface: Vlan1
    helper: 1.1.1.1
  - interface: Vlan20
    helper: 1.1.1.1
  - interface: Vlan60
    helper: 1.1.1.99 
$ cat host_vars/pynet-rtr2/dhcp.yml 
---
dhcp_interfaces:
  - interface: Vlan1
    helper: 1.1.1.1
  - interface: Vlan20
    helper: 1.1.1.1
  - interface: Vlan40
    helper: 1.1.1.254
  - interface: Vlan99
    helper: 1.1.1.253

These two 'dhcp_interfaces' variables will now be specific to pynet-rtr1 and pynet-rtr2, respectively.

Let execute our playbook and see what happens:

$ ansible-playbook helper_config.yml -i ./ansible-hosts

PLAY [Configure ip-helper on single router] ************************************

TASK [setup] *******************************************************************
ok: [pynet-rtr2]
ok: [pynet-rtr1]

TASK [ios_config] **************************************************************
changed: [pynet-rtr2] => (item={u'interface': u'Vlan1', u'helper': u'1.1.1.1'})
ok: [pynet-rtr1] => (item={u'interface': u'Vlan1', u'helper': u'1.1.1.1'})
changed: [pynet-rtr2] => (item={u'interface': u'Vlan20', u'helper': u'1.1.1.1'})
ok: [pynet-rtr1] => (item={u'interface': u'Vlan20', u'helper': u'1.1.1.1'})
changed: [pynet-rtr2] => (item={u'interface': u'Vlan40', u'helper': u'1.1.1.254'})
ok: [pynet-rtr1] => (item={u'interface': u'Vlan60', u'helper': u'1.1.1.99'})
changed: [pynet-rtr2] => (item={u'interface': u'Vlan99', u'helper': u'1.1.1.253'})

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

Note, pynet-rtr1 was unchanged as it already had all of its ip helpers from our previous playbook execution

If we verify pynet-rtr2, we see the following:

pynet-rtr2#show run | section interface
...omitted...
interface Vlan1
 no ip address
 ip helper-address 1.1.1.1
interface Vlan20
 no ip address
 ip helper-address 1.1.1.1
interface Vlan40
 no ip address
 ip helper-address 1.1.1.254
interface Vlan99
 no ip address
 ip helper-address 1.1.1.253

This takes care of the two Cisco routers—let's now add the four Arista switches.

Here I will use group_vars since the Arista switches all share the same dhcp_interfaces variable.

My group_vars directory structure is as follows:

$ tree group_vars/
group_vars/
└── arista.yml

0 directories, 1 file 
$ cat group_vars/arista.yml 
---
dhcp_interfaces:
  - interface: Vlan1
    helper: 1.1.1.1
  - interface: Vlan60
    helper: 1.1.1.88
  - interface: Vlan99
    helper: 1.1.1.253

Now I should be able to just expand my play to make the hosts refer to 'cisco:arista'. Consequently, my playbook looks as follows:

---
- name: Configure ip-helper on single router
  hosts: cisco:arista
  vars:
    creds:
        host: "{{ ansible_host }}"
        username: "{{ username }}"
        password: "{{ password }}"

  tasks:
    - ios_config:
        provider: "{{ creds }}"
        lines:
          - ip helper-address {{ item.helper }}
        parents: interface {{ item.interface }}
      with_items: "{{ dhcp_interfaces }}"

Note, I took a bit of a shortcut here in that I reasoned that the 'ios_config' module would probably also work for Arista. Alternatively, I could have used the 'eos_config' module, but some additional logic would be required.

Executing this playbook, yields the following:

$ ansible-playbook helper_config.yml -i ./ansible-hosts

PLAY [Configure ip-helper on single router] ************************************

TASK [setup] *******************************************************************
ok: [pynet-sw7]
ok: [pynet-rtr1]
ok: [pynet-sw6]
ok: [pynet-sw5]
ok: [pynet-rtr2]
ok: [pynet-sw8]

TASK [ios_config] **************************************************************
changed: [pynet-rtr1] => (item={u'interface': u'Vlan60', u'helper': u'1.1.1.99'})
changed: [pynet-rtr2] => (item={u'interface': u'Vlan40', u'helper': u'1.1.1.254'})
changed: [pynet-rtr1] => (item={u'interface': u'Vlan20', u'helper': u'1.1.1.1'})
changed: [pynet-rtr2] => (item={u'interface': u'Vlan20', u'helper': u'1.1.1.1'})
changed: [pynet-rtr1] => (item={u'interface': u'Vlan1', u'helper': u'1.1.1.1'})
changed: [pynet-rtr2] => (item={u'interface': u'Vlan1', u'helper': u'1.1.1.1'})
changed: [pynet-rtr2] => (item={u'interface': u'Vlan99', u'helper': u'1.1.1.253'})
changed: [pynet-sw5] => (item={u'interface': u'Vlan1', u'helper': u'1.1.1.1'})
changed: [pynet-sw6] => (item={u'interface': u'Vlan1', u'helper': u'1.1.1.1'})
changed: [pynet-sw7] => (item={u'interface': u'Vlan1', u'helper': u'1.1.1.1'})
changed: [pynet-sw8] => (item={u'interface': u'Vlan1', u'helper': u'1.1.1.1'})
changed: [pynet-sw6] => (item={u'interface': u'Vlan60', u'helper': u'1.1.1.88'})
changed: [pynet-sw5] => (item={u'interface': u'Vlan60', u'helper': u'1.1.1.88'})
changed: [pynet-sw7] => (item={u'interface': u'Vlan60', u'helper': u'1.1.1.88'})
changed: [pynet-sw8] => (item={u'interface': u'Vlan60', u'helper': u'1.1.1.88'})
changed: [pynet-sw6] => (item={u'interface': u'Vlan99', u'helper': u'1.1.1.253'})
changed: [pynet-sw5] => (item={u'interface': u'Vlan99', u'helper': u'1.1.1.253'})
changed: [pynet-sw7] => (item={u'interface': u'Vlan99', u'helper': u'1.1.1.253'})
changed: [pynet-sw8] => (item={u'interface': u'Vlan99', u'helper': u'1.1.1.253'})

PLAY RECAP *********************************************************************
pynet-rtr1                 : ok=2    changed=1    unreachable=0    failed=0   
pynet-rtr2                 : ok=2    changed=1    unreachable=0    failed=0   
pynet-sw5                  : ok=2    changed=1    unreachable=0    failed=0   
pynet-sw6                  : ok=2    changed=1    unreachable=0    failed=0   
pynet-sw7                  : ok=2    changed=1    unreachable=0    failed=0   
pynet-sw8                  : ok=2    changed=1    unreachable=0    failed=0

Note, I executed this playbook a day later (than my previous executions). Consequently, the lab environment was automatically reset to its known-good state (I do this nightly). Because of this, all of the IP helper changes had to be pushed again (even for pynet-rtr1 and pynet-rtr2).

Also note, I verified that all of the helpers were actually configured (using netmiko-tools to grab the interfaces section for all of the devices).

This completes the configuration of the DHCP helpers.

Kirk Byers

@kirkbyers

You might also be interested in: