Ansible Resource Modules on IOS XE: VLANs, Trunks and SVIs from One Data Model

One YAML data model drives VLANs, trunks, access and voice ports and SVIs on three IOS XE switches with the cisco.ios resource modules. Check mode command lists, a changed=0 rerun, an ip routing idempotency trap, and real show vlan, trunk and ping output.

Lab diagram: one Ansible data model configuring three Cisco IOS XE 17.18.2 switches. On the left, a card for vars/campus.yml lists VLANs 10 USERS, 20 VOICE, 30 PRINTERS, 90 SW-MGMT and 999 NA

This is part 10 of the Ansible for network engineers series. Up to now the series has mostly treated config as lines of text: templates, ios_config, and the parsers in part 8. This post switches to the resource modules for a small HQ campus. One YAML file describes the VLANs, the trunks, the access ports and the SVIs, and five module calls turn it into config on three switches.

Built in my home lab with ansible-core 2.21.5, cisco.ios 11.6.0, ansible.netcommon 8.7.1 and Python 3.14, against three Cisco IOS XE 17.18.2 Layer 2 lab images.

The campus

Lab diagram: one Ansible data model configuring three Cisco IOS XE 17.18.2 switches. On the left, a card for vars/campus.yml lists VLANs 10 USERS, 20 VOICE, 30 PRINTERS, 90 SW-MGMT and 999 NATIVE-PARK, used only as the trunk native VLAN, plus the svis, ports and mgmt_svi keys. A teal arrow leads to an Ansible block for 100-campus.yml with five module tasks in order: ios_vlans merged, ios_interfaces merged for ports, ios_l2_interfaces replaced, ios_interfaces merged for SVIs, and ios_l3_interfaces merged. A teal arrow continues to the distribution switch hq-dist1, management 10.66.190.1, whose SVIs are Vlan10 10.66.110.1/24, Vlan20 10.66.120.1/24, Vlan30 10.66.130.1/24 and Vlan90 10.66.190.1/24. Two 802.1Q trunks, allowed VLANs 10,20,30,90 with native VLAN 999, run from hq-dist1 Et0/1 to hq-acc1 Et0/1 and from hq-dist1 Et0/2 to hq-acc2 Et0/1. hq-acc1, Vlan90 10.66.190.11, has Et0/2 as desk 2F-14 with data VLAN 10 and voice VLAN 20, and Et0/3 as a printer port in VLAN 30. hq-acc2, Vlan90 10.66.190.12, has Et0/2 and Et0/3 as desks 3F-02 and 3F-03 with data 10 and voice 20. A footer strip reads: check mode commands list, apply, rerun changed=0 on all three.
One data model, three switches: VLANs, trunks, access ports and SVIs

Three switches. hq-dist1 is the distribution switch and holds the SVIs. hq-acc1 and hq-acc2 are access switches, each with one uplink trunk to hq-dist1.

Switch Port Role
hq-dist1 Et0/1 Trunk to hq-acc1 Et0/1
hq-dist1 Et0/2 Trunk to hq-acc2 Et0/1
hq-acc1 Et0/2 Desk: data VLAN 10, voice VLAN 20
hq-acc1 Et0/3 Printer: VLAN 30
hq-acc2 Et0/2, Et0/3 Desks: data VLAN 10, voice VLAN 20

VLAN 90 is switch management. hq-dist1 is 10.66.190.1, the access switches are .11 and .12. VLAN 999 is a parking VLAN used only as the trunk native VLAN, so untagged traffic on a trunk lands somewhere with nothing in it.

Why resource modules

ios_config compares lines. It can tell you a line is missing, but it can't tell you that a trunk allows VLAN 40 when it shouldn't. Resource modules parse the device into structured data, compare that with what you asked for and generate only the commands needed. The state you pick decides how strict that comparison is:

  • merged adds what you list and leaves everything else alone.
  • replaced makes each listed interface match your data exactly and removes anything extra on it.
  • overridden does the same for every interface on the box, listed or not.
  • deleted removes it.

I use merged for VLANs and SVIs, where adding is the normal case, and replaced for the switchport settings, because a stray allowed VLAN or a leftover access VLAN is exactly the drift you want removed. I'd stay away from overridden on access switches until every port is in the data model, or it will wipe the ones that aren't.

One data model

Everything about the campus lives in vars/campus.yml:

# vars/campus.yml
---
# One data model for the HQ campus: VLANs, trunks, access ports, SVIs
campus_vlans:
  - {vlan_id: 10, name: USERS}
  - {vlan_id: 20, name: VOICE}
  - {vlan_id: 30, name: PRINTERS}
  - {vlan_id: 90, name: SW-MGMT}
  - {vlan_id: 999, name: NATIVE-PARK}

trunk_vlans: [10, 20, 30, 90]
native_vlan: 999

svis:
  - {vlan: 10, ip: 10.66.110.1/24}
  - {vlan: 20, ip: 10.66.120.1/24}
  - {vlan: 30, ip: 10.66.130.1/24}
  - {vlan: 90, ip: 10.66.190.1/24}

ports:
  hq-dist1:
    - {name: Ethernet0/1, mode: trunk, desc: "TRUNK: hq-acc1 Et0/1"}
    - {name: Ethernet0/2, mode: trunk, desc: "TRUNK: hq-acc2 Et0/1"}
  hq-acc1:
    - {name: Ethernet0/1, mode: trunk, desc: "UPLINK: hq-dist1 Et0/1"}
    - {name: Ethernet0/2, mode: access, vlan: 10, voice: 20, desc: "DESK: 2F-14"}
    - {name: Ethernet0/3, mode: access, vlan: 30, desc: "PRINTER: 2F-copy room"}
  hq-acc2:
    - {name: Ethernet0/1, mode: trunk, desc: "UPLINK: hq-dist1 Et0/2"}
    - {name: Ethernet0/2, mode: access, vlan: 10, voice: 20, desc: "DESK: 3F-02"}
    - {name: Ethernet0/3, mode: access, vlan: 10, voice: 20, desc: "DESK: 3F-03"}

mgmt_svi:
  hq-acc1: 10.66.190.11/24
  hq-acc2: 10.66.190.12/24

Nothing in there is IOS syntax. It's the same facts you'd put in a port spreadsheet.

The playbook

The resource modules take a list of interfaces in one call, so the first two tasks reshape the data model into that list. One module call per resource is much faster than looping the module once per port, which I tried first and gave up on.

# playbooks/100-campus.yml
---
- name: HQ campus VLANs, trunks, access ports and SVIs
  hosts: campus
  gather_facts: false
  vars_files:
    - ../vars/campus.yml
  vars:
    my_ports: "{{ ports[inventory_hostname] }}"
    my_svis: "{{ svis if inventory_hostname == 'hq-dist1' else [{'vlan': 90, 'ip': mgmt_svi[inventory_hostname]}] }}"
    trunk_def:
      encapsulation: dot1q
      allowed_vlans: "{{ trunk_vlans | map('string') | list }}"
      native_vlan: "{{ native_vlan }}"
  tasks:
    - name: Ports from the data model into module input
      ansible.builtin.set_fact:
        if_cfg: "{{ if_cfg | default([]) + [{'name': item.name, 'description': item.desc, 'enabled': true}] }}"
        l2_cfg: >-
          {{ l2_cfg | default([]) + [{'name': item.name, 'mode': item.mode}
          | combine({'trunk': trunk_def} if item.mode == 'trunk' else {'access': {'vlan': item.vlan}})
          | combine({'voice': {'vlan': item.voice}} if item.voice is defined else {})] }}
      loop: "{{ my_ports }}"
      loop_control:
        label: "{{ item.name }}"

    - name: SVIs from the data model into module input
      ansible.builtin.set_fact:
        svi_cfg: "{{ svi_cfg | default([]) + [{'name': 'Vlan' ~ item.vlan, 'enabled': true}] }}"
        svi_l3: "{{ svi_l3 | default([]) + [{'name': 'Vlan' ~ item.vlan, 'ipv4': [{'address': item.ip}]}] }}"
      loop: "{{ my_svis }}"
      loop_control:
        label: "Vlan{{ item.vlan }}"

    - name: VLAN database
      cisco.ios.ios_vlans:
        config: "{{ campus_vlans }}"
        state: merged
      register: r_vlans

    - name: Port descriptions, ports enabled
      cisco.ios.ios_interfaces:
        config: "{{ if_cfg }}"
        state: merged
      register: r_ports

    - name: Trunk or access, per the data model
      cisco.ios.ios_l2_interfaces:
        config: "{{ l2_cfg }}"
        state: replaced
      register: r_l2

    - name: SVIs exist and are up
      cisco.ios.ios_interfaces:
        config: "{{ svi_cfg }}"
        state: merged
      register: r_svi

    - name: SVI addresses
      cisco.ios.ios_l3_interfaces:
        config: "{{ svi_l3 }}"
        state: merged
      register: r_l3

    - name: Commands sent, or that check mode would send
      ansible.builtin.debug:
        msg: >-
          {{ (r_vlans.commands | default([])) + (r_ports.commands | default([]))
          + (r_l2.commands | default([])) + (r_svi.commands | default([]))
          + (r_l3.commands | default([])) }}

Two details that bit me:

  • allowed_vlans wants a list of strings. Passing the integer list straight from the data model fails validation, hence map('string') | list.
  • The VLAN interfaces and their addresses are two modules. ios_interfaces creates the SVI and brings it up, ios_l3_interfaces puts the address on it.

The last task prints the commands each module generated. Resource modules don't show anything with --diff, but they do return a commands list, in check mode too.

Check mode first

ansible-playbook -i campus-inv/hosts.yml playbooks/100-campus.yml --check

Against the three bare switches, this is what hq-acc1 would get:

ok: [hq-acc1] => {
    "msg": [
        "vlan 20",
        "name VOICE",
        "vlan 90",
        "name SW-MGMT",
        "vlan 999",
        "name NATIVE-PARK",
        "vlan 10",
        "name USERS",
        "vlan 30",
        "name PRINTERS",
        "interface Ethernet0/1",
        "description UPLINK: hq-dist1 Et0/1",
        "interface Ethernet0/2",
        "description DESK: 2F-14",
        "interface Ethernet0/3",
        "description PRINTER: 2F-copy room",
        "interface Ethernet0/1",
        "switchport trunk encapsulation dot1q",
        "switchport mode trunk",
        "switchport trunk native vlan 999",
        "switchport trunk allowed vlan 10,20,30,90",
        "interface Ethernet0/2",
        "switchport access vlan 10",
        "switchport voice vlan 20",
        "switchport mode access",
        "interface Ethernet0/3",
        "switchport access vlan 30",
        "switchport mode access",
        "interface Vlan90",
        "no shutdown",
        "interface Vlan90",
        "ip address 10.66.190.11 255.255.255.0"
    ]
}

hq-dist1 got the same VLANs, two trunks, four SVIs with no shutdown and the four gateway addresses. That list is what I review before anything touches a switch.

Apply, then run it again

My first apply hit a command timeout on hq-acc2 halfway through. Running the playbook again finished the job, and because the modules compare state, the switches that were already done reported nothing to change. The recap from that second run:

hq-acc1                    : ok=8    changed=0    unreachable=0    failed=0
hq-acc2                    : ok=8    changed=3    unreachable=0    failed=0
hq-dist1                   : ok=9    changed=1    unreachable=0    failed=0

hq-dist1 shows changed=1 here for a reason worth knowing about. My first version had an ios_config task pushing ip routing so the SVIs would route. On this image ip routing is on by default and doesn't appear in the running config, so ios_config never sees the line and sends it on every run. A playbook that always reports a change is useless for spotting drift, so I removed the task and checked routing with show ip route connected instead. If your platform really has routing off by default, keep the task and check show ip protocols first.

The real test is the next run, with nothing changed in the data:

PLAY RECAP *********************************************************************
hq-acc1                    : ok=8    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
hq-acc2                    : ok=8    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
hq-dist1                   : ok=8    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

changed=0 on all three. That run took 2 minutes 40 seconds for three switches, mostly the modules reading the config back.

Connection settings that made it reliable

My lab host was busy while I wrote this, and the default timeouts weren't enough. These are in group_vars/all/connection.yml:

ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
ansible_user: netauto
ansible_become: true
ansible_become_method: enable
ansible_command_timeout: 120
ansible_ssh_timeout: 60
ansible_connect_timeout: 120
ansible_network_cli_ssh_type: paramiko

The 120 second command timeout is there because under load a full running config took about a minute to come back. The resource modules read config on every call, so that matters more here than with ios_command.

The paramiko line fixed a failure I couldn't fix with timeouts. With the default libssh transport, the fourth module call in the play kept failing with command timeout triggered, timeout value is 120 secs on the same task, run after run, while the same module run on its own finished in about 5 seconds. Switching ansible_network_cli_ssh_type to paramiko gave a clean run first time. If you see a task time out reliably at the same place mid play, try the other transport before you raise the timeout again.

Prove it works

A second playbook reads the result back:

# playbooks/101-campus-verify.yml
---
- name: Prove the campus works
  hosts: campus
  gather_facts: false
  tasks:
    - name: VLANs and trunks on every switch
      cisco.ios.ios_command:
        commands:
          - show vlan brief
          - show interfaces trunk
      register: l2

    - name: Print them
      ansible.builtin.debug:
        var: l2.stdout_lines

    - name: SVIs and routes on the distribution switch, ping both access switches
      cisco.ios.ios_command:
        commands:
          - show ip interface brief | include Vlan
          - show ip route connected | include Vlan
          - ping 10.66.190.11 repeat 5
          - ping 10.66.190.12 repeat 5
      register: l3
      when: inventory_hostname == 'hq-dist1'

    - name: Print them
      ansible.builtin.debug:
        var: l3.stdout_lines
      when: inventory_hostname == 'hq-dist1'

VLANs and the trunk on hq-acc1. The desk port is in 10 and 20, the printer in 30, and the trunk carries only the four VLANs in the data model with 999 native:

VLAN Name                             Status    Ports
---- -------------------------------- --------- -------------------------------
1    default                          active
10   USERS                            active    Et0/2
20   VOICE                            active    Et0/2
30   PRINTERS                         active    Et0/3
90   SW-MGMT                          active
999  NATIVE-PARK                      active

Port           Mode             Encapsulation  Status        Native vlan
Et0/1          on               802.1q         trunking      999

Port           Vlans allowed on trunk
Et0/1          10,20,30,90

Port           Vlans in spanning tree forwarding state and not pruned
Et0/1          10,20,30,90

Both trunks on hq-dist1:

Port           Mode             Encapsulation  Status        Native vlan
Et0/1          on               802.1q         trunking      999
Et0/2          on               802.1q         trunking      999

Port           Vlans allowed on trunk
Et0/1          10,20,30,90
Et0/2          10,20,30,90

The SVIs on hq-dist1, their connected routes, and a ping to each access switch across the management VLAN:

Vlan10                 10.66.110.1     YES manual up                    up
Vlan20                 10.66.120.1     YES manual up                    up
Vlan30                 10.66.130.1     YES manual up                    up
Vlan90                 10.66.190.1     YES manual up                    up

C        10.66.110.0/24 is directly connected, Vlan10
C        10.66.120.0/24 is directly connected, Vlan20
C        10.66.130.0/24 is directly connected, Vlan30
C        10.66.190.0/24 is directly connected, Vlan90

Sending 5, 100-byte ICMP Echos to 10.66.190.11, timeout is 2 seconds:
.!!!!
Success rate is 80 percent (4/5), round-trip min/avg/max = 1/1/2 ms
Sending 5, 100-byte ICMP Echos to 10.66.190.12, timeout is 2 seconds:
.!!!!
Success rate is 80 percent (4/5), round-trip min/avg/max = 1/1/2 ms

The first dot on each ping is ARP resolving, not loss. Those pings cross the trunk, so they prove the trunk, the native VLAN choice and both ends of VLAN 90 at once.

What I'd do in production

Keep the data model in Git and review the check mode command list in the pull request. Use replaced on the ports you manage and add ports to the model before you ever think about overridden. Don't put a line in a playbook that the platform hides from the running config, or you'll lose changed=0 as a signal. Rerun after every change and expect nothing to change.

Next in the series: compliance and drift checks across every device, then fixing what they find.

Read next