This is part 13 of the Ansible for network engineers series. Part 12 turned show commands into data. This post uses that data for routing. One vars file drives OSPF and BGP through the resource modules, and every run checks before and after that the neighbors came up and no routes went missing.
The topology is five IOS XE routers. hq-core1, hq-core2 and hq-edge1 run OSPF area 0. hq-edge1 is AS 65000 and peers eBGP with br1-edge1 (AS 65001) and br2-edge1 (AS 65002). Each router has a Loopback0 in 10.66.255.0/24, and every link is a /30.
Built in my home lab with ansible-core 2.21.5, cisco.ios 11.6.0, ansible.netcommon 8.7.1, ansible.utils 6.1.1 and ntc_templates 9.3.0, against Cisco IOS XE 17.18.2 (lab images).

The data
The whole routing design lives in one file. Interface and neighbor entries are already in the shape the modules expect, so the playbook passes them through without rebuilding them in Jinja:
# vars/routing.yml
loopbacks:
hq-core1: 10.66.255.11
hq-core2: 10.66.255.12
hq-edge1: 10.66.255.13
br1-edge1: 10.66.255.21
br2-edge1: 10.66.255.31
p2p:
hq-core1:
- {name: Ethernet0/1, ipv4: [{address: 10.66.101.1/30}]}
- {name: Ethernet0/2, ipv4: [{address: 10.66.102.1/30}]}
hq-core2:
- {name: Ethernet0/1, ipv4: [{address: 10.66.101.2/30}]}
hq-edge1:
- {name: Ethernet0/1, ipv4: [{address: 10.66.102.2/30}]}
- {name: Ethernet0/2, ipv4: [{address: 10.66.103.1/30}]}
- {name: Ethernet0/3, ipv4: [{address: 10.66.104.1/30}]}
br1-edge1:
- {name: Ethernet0/1, ipv4: [{address: 10.66.103.2/30}]}
br2-edge1:
- {name: Ethernet0/1, ipv4: [{address: 10.66.104.2/30}]}
ospf_hosts: [hq-core1, hq-core2, hq-edge1]
ospf_links:
hq-core1: [{name: Ethernet0/1}, {name: Ethernet0/2}, {name: Loopback0}]
hq-core2: [{name: Ethernet0/1}, {name: Loopback0}]
hq-edge1: [{name: Ethernet0/1}, {name: Loopback0}]
bgp:
hq-edge1:
asn: 65000
neighbors:
- {neighbor_address: 10.66.103.2, remote_as: 65001}
- {neighbor_address: 10.66.104.2, remote_as: 65002}
networks:
- {address: 10.66.255.11, mask: 255.255.255.255}
- {address: 10.66.255.12, mask: 255.255.255.255}
- {address: 10.66.255.13, mask: 255.255.255.255}
br1-edge1:
asn: 65001
neighbors: [{neighbor_address: 10.66.103.1, remote_as: 65000}]
networks:
- {address: 10.66.255.21, mask: 255.255.255.255}
br2-edge1:
asn: 65002
neighbors: [{neighbor_address: 10.66.104.1, remote_as: 65000}]
networks:
- {address: 10.66.255.31, mask: 255.255.255.255}
# what "healthy" means, checked before and after every change
expect:
hq-core1: {ospf: 2, bgp: 0}
hq-core2: {ospf: 1, bgp: 0}
hq-edge1: {ospf: 1, bgp: 2}
br1-edge1: {ospf: 0, bgp: 1}
br2-edge1: {ospf: 0, bgp: 1}
The expect block is the part people skip. It says what healthy looks like on each router: two FULL OSPF neighbors on hq-core1, one OSPF neighbor and two BGP sessions on hq-edge1, and so on. Without it, a post-check can tell you something changed but not whether the result is right.
The BGP network masks are dotted quads on purpose. The ansible.utils.ipaddr filter can convert a prefix for you, but it needs the netaddr Python package, and three /32s didn't justify another dependency.
The checks
The pre-check and post-check are the same task file, included twice. It parses three show commands with the ntc templates from part 12 and reduces them to two counts and a route list:
# playbooks/tasks/route-check.yml
- name: Parse OSPF neighbors
ansible.utils.cli_parse:
command: show ip ospf neighbor
parser:
name: ansible.netcommon.ntc_templates
set_fact: ospf_nbrs
when: expect[inventory_hostname].ospf > 0
- name: Parse BGP summary
ansible.utils.cli_parse:
command: show ip bgp summary
parser:
name: ansible.netcommon.ntc_templates
set_fact: bgp_nbrs
when: expect[inventory_hostname].bgp > 0
- name: Parse the routing table
ansible.utils.cli_parse:
command: show ip route
parser:
name: ansible.netcommon.ntc_templates
set_fact: rib
- name: Boil it down to what we compare
ansible.builtin.set_fact:
health:
ospf_full: "{{ ospf_nbrs | default([]) | selectattr('state', 'match', 'FULL') | list | length }}"
bgp_up: "{{ bgp_nbrs | default([]) | selectattr('state_or_prefixes_received', 'match', '^[0-9]+$') | list | length }}"
routes: "{{ rib | selectattr('protocol', 'in', ['O', 'B']) | map(attribute='network') | zip(rib | selectattr('protocol', 'in', ['O', 'B']) | map(attribute='prefix_length')) | map('join', '/') | unique | sort }}"
bgp_up counts rows where the State/PfxRcd column is a number. A session that's down shows Idle or Active there, so it doesn't count. routes holds every O and B route as a prefix string, which turns the before and after comparison into a plain list difference.
The playbook
---
- name: OSPF in HQ, eBGP to the branches
hosts: sites:!br1-acc1
gather_facts: false
vars_files:
- ../vars/routing.yml
vars:
links: "{{ p2p[inventory_hostname] }}"
me: "{{ bgp[inventory_hostname] | default({}) }}"
lo0: "{{ loopbacks[inventory_hostname] }}"
ospf_af:
address_family:
- {afi: ipv4, process: {id: 1, area_id: "0"}}
tasks:
- name: Pre-check
ansible.builtin.include_tasks: tasks/route-check.yml
- name: Keep the pre-change picture
ansible.builtin.set_fact:
pre: "{{ health }}"
- name: Bring the point-to-point links up
cisco.ios.ios_interfaces:
config: "{{ links | ansible.utils.keep_keys(target=['name']) | map('combine', {'enabled': true}) | list }}"
state: merged
- name: Point-to-point addressing, replacing whatever was there
cisco.ios.ios_l3_interfaces:
config: "{{ links }}"
state: replaced
- name: OSPF process
cisco.ios.ios_ospfv2:
config:
processes:
- process_id: 1
router_id: "{{ lo0 }}"
state: merged
when: inventory_hostname in ospf_hosts
- name: OSPF on the core links and Loopback0
cisco.ios.ios_ospf_interfaces:
config: "{{ ospf_links[inventory_hostname] | map('combine', ospf_af) | list }}"
state: merged
when: inventory_hostname in ospf_hosts
- name: BGP process and neighbors
cisco.ios.ios_bgp_global:
config:
as_number: "{{ me.asn }}"
bgp:
router_id:
address: "{{ lo0 }}"
log_neighbor_changes: true
neighbors: "{{ me.neighbors }}"
state: merged
when: me | length > 0
- name: BGP networks
cisco.ios.ios_bgp_address_family:
config:
as_number: "{{ me.asn }}"
address_family:
- afi: ipv4
networks: "{{ me.networks }}"
state: merged
when: me | length > 0
- name: Wait for OSPF neighbors to reach FULL
cisco.ios.ios_command:
commands: show ip ospf neighbor
register: w_ospf
until: w_ospf.stdout[0] | regex_findall('FULL/') | length >= expect[inventory_hostname].ospf
retries: 12
delay: 10
when: expect[inventory_hostname].ospf > 0
- name: Wait for BGP sessions to be established
cisco.ios.ios_command:
commands: show ip bgp summary
register: w_bgp
until: w_bgp.stdout[0] | regex_findall('(?m)^\d+\.\d+\.\d+\.\d+\s.*\s\d+$') | length >= expect[inventory_hostname].bgp
retries: 12
delay: 10
when: expect[inventory_hostname].bgp > 0
- name: Post-check
ansible.builtin.include_tasks: tasks/route-check.yml
- name: Assert the network is what the data says it should be
ansible.builtin.assert:
that:
- health.ospf_full | int == expect[inventory_hostname].ospf
- health.bgp_up | int == expect[inventory_hostname].bgp
- pre.routes | difference(health.routes) | length == 0
fail_msg: "ospf {{ health.ospf_full }} bgp {{ health.bgp_up }} lost {{ pre.routes | difference(health.routes) }}"
success_msg: "ospf {{ health.ospf_full }} bgp {{ health.bgp_up }}, {{ health.routes | length }} routes ({{ health.routes | difference(pre.routes) | length }} new, 0 lost)"
A few of the choices need explaining:
ios_l3_interfacesusesreplaced, so the vars file is the only source of addressing on those interfaces. On the first real run that removed a stray secondary address on hq-core1 Ethernet0/2.mergedwould have left it in place.- The OSPF interface entries come from
map('combine', ospf_af). Every interface gets the same address family block, and adding a link is one line in the data. - The routing protocol tasks use
merged. A playbook that only describes part of the BGP config shouldn't delete everything else underrouter bgp. If you want the playbook to own the whole process,replacedoroverriddenis stricter. Try that on a lab copy first. - The assert fails if any route from the pre-check is gone, even when the neighbor counts look fine.
Why the waits don't use cli_parse
My first version polled with cli_parse inside an until loop. Every OSPF router failed with this:
"errors": ["parameters are mutually exclusive: command|text"], "msg": "argspec validation failed for cli_parse module plugin"
I reproduced it with a small loop that's forced to retry. The first attempt runs, and every retry fails argument validation. In ansible.utils 6.1.1 you can't retry cli_parse with until. So the waits use ios_command with a regex count, FULL/ for OSPF and rows ending in a prefix count for BGP. The real parse runs once, after the network has settled.
First run
I started from bare interfaces: links shut, no addresses, no routing processes. The first run:
TASK [Assert the network is what the data says it should be] *******************
ok: [hq-core2] => {
"changed": false,
"msg": "ospf 1 bgp 0, 3 routes (3 new, 0 lost)"
}
ok: [hq-edge1] => {
"changed": false,
"msg": "ospf 1 bgp 2, 5 routes (5 new, 0 lost)"
}
ok: [hq-core1] => {
"changed": false,
"msg": "ospf 2 bgp 0, 2 routes (2 new, 0 lost)"
}
ok: [br1-edge1] => {
"changed": false,
"msg": "ospf 0 bgp 1, 4 routes (4 new, 0 lost)"
}
ok: [br2-edge1] => {
"changed": false,
"msg": "ospf 0 bgp 1, 4 routes (4 new, 0 lost)"
}
PLAY RECAP *********************************************************************
br1-edge1 : ok=15 changed=4 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
br2-edge1 : ok=15 changed=4 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
hq-core1 : ok=15 changed=4 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
hq-core2 : ok=15 changed=4 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
hq-edge1 : ok=20 changed=6 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The pre-check found nothing, so every route counts as new. The counts match the design. hq-core1 has two routes, the hq-core2 and hq-edge1 loopbacks. It doesn't learn the branch loopbacks because nothing redistributes BGP into OSPF here, and that's deliberate for this post. hq-edge1 has five: two loopbacks and the core1 to core2 link from OSPF, and both branch loopbacks from BGP.
The second run changed nothing on any device, and every assert reported 0 new, 0 lost:
PLAY RECAP *********************************************************************
br1-edge1 : ok=15 changed=0 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
br2-edge1 : ok=15 changed=0 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
hq-core1 : ok=15 changed=0 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
hq-core2 : ok=15 changed=0 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
hq-edge1 : ok=20 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
That run didn't pass first time. Twice, br1-edge1 dropped out with a 120 second command timeout, once in a parse task and once in ios_interfaces. Ad hoc commands to it took five seconds right afterwards, and the third run was clean. Watch the PLAY RECAP for this. A host that fails early skips its assert, so the assert lines alone look like a pass.
A change that bounces the neighbors
The real test is a change that drops adjacencies. Setting ip ospf network point-to-point on the OSPF interfaces does that. It also makes sense on /30 links, because there's no DR election to wait for. It's one line in the play vars:
@@ -10,7 +10,7 @@
lo0: "{{ loopbacks[inventory_hostname] }}"
ospf_af:
address_family:
- - {afi: ipv4, process: {id: 1, area_id: "0"}}
+ - {afi: ipv4, process: {id: 1, area_id: "0"}, network: {point_to_point: true}}
tasks:
- name: Pre-check
ansible.builtin.include_tasks: tasks/route-check.yml
The run:
TASK [OSPF on the core links and Loopback0] ************************************
skipping: [br1-edge1]
skipping: [br2-edge1]
changed: [hq-core2]
changed: [hq-edge1]
changed: [hq-core1]
...
TASK [Assert the network is what the data says it should be] *******************
ok: [hq-core1] => {
"changed": false,
"msg": "ospf 2 bgp 0, 2 routes (0 new, 0 lost)"
}
ok: [hq-edge1] => {
"changed": false,
"msg": "ospf 1 bgp 2, 5 routes (0 new, 0 lost)"
}
ok: [br1-edge1] => {
"changed": false,
"msg": "ospf 0 bgp 1, 4 routes (0 new, 0 lost)"
}
ok: [br2-edge1] => {
"changed": false,
"msg": "ospf 0 bgp 1, 4 routes (0 new, 0 lost)"
}
ok: [hq-core2] => {
"changed": false,
"msg": "ospf 1 bgp 0, 3 routes (0 new, 0 lost)"
}
PLAY RECAP *********************************************************************
br1-edge1 : ok=15 changed=0 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
br2-edge1 : ok=15 changed=0 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
hq-core1 : ok=15 changed=1 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
hq-core2 : ok=15 changed=1 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
hq-edge1 : ok=20 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Only ios_ospf_interfaces changed, once on each HQ router. The neighbors are now FULL/ -, with no DR or BDR:
hq-core1# show ip ospf neighbor
Neighbor ID Pri State Dead Time Address Interface
10.66.255.12 0 FULL/ - 00:00:39 10.66.101.2 Ethernet0/1
10.66.255.13 0 FULL/ - 00:00:38 10.66.102.2 Ethernet0/2
The run passed, but look at the branch side straight afterwards:
br1-edge1# show ip route bgp | b ^Gateway
Gateway of last resort is not set
10.0.0.0/8 is variably subnetted, 7 subnets, 2 masks
B 10.66.255.11/32 [20/11] via 10.66.103.1, 00:01:42
B 10.66.255.12/32 [20/21] via 10.66.103.1, 00:01:42
B 10.66.255.13/32 [20/0] via 10.66.103.1, 00:15:03
B 10.66.255.31/32 [20/0] via 10.66.103.1, 00:15:03
10.66.255.11 and .12 are 1 minute 42 seconds old, while the other two routes have been up for 15 minutes. When the adjacencies dropped, hq-edge1 lost its OSPF routes to the core loopbacks. Its network statements stopped matching, so it withdrew both prefixes from the branches, then advertised them again once OSPF came back. The post-check couldn't see any of that, because it compares two snapshots and the gap fell between them. Pre and post checks tell you the network ended up where it should. They don't tell you nothing broke in between. If that gap matters, schedule the change for a window, or poll the branches during the run.

Leaving it in place
I kept the point-to-point version as the routing baseline for the rest of the series. I also wrote a reset playbook that purges BGP, removes the OSPF config and addressing, and shuts the links, so I can rerun the first run from scratch:
---
- name: Remove the routing build (lab reset)
hosts: sites:!br1-acc1
gather_facts: false
vars_files:
- ../vars/routing.yml
vars:
links: "{{ p2p[inventory_hostname] }}"
tasks:
- name: Remove BGP
cisco.ios.ios_bgp_global:
state: purged
when: inventory_hostname in bgp
- name: Remove OSPF interface config
cisco.ios.ios_ospf_interfaces:
config: "{{ ospf_links[inventory_hostname] }}"
state: deleted
when: inventory_hostname in ospf_hosts
- name: Remove the OSPF process
cisco.ios.ios_ospfv2:
state: deleted
when: inventory_hostname in ospf_hosts
- name: Remove link addressing
cisco.ios.ios_l3_interfaces:
config: "{{ links | ansible.utils.keep_keys(target=['name']) }}"
state: deleted
- name: Shut the links
cisco.ios.ios_interfaces:
config: "{{ links | ansible.utils.keep_keys(target=['name']) | map('combine', {'enabled': false}) | list }}"
state: merged
ios_bgp_global with state: purged removes the whole router bgp block. deleted leaves the router bgp process itself in place, which isn't what a reset needs.
Part 14 uses these checks to gate a rolling change with serial, with automatic rollback. The rest of the series is on the series page.