OSPF and BGP with Ansible Resource Modules, Checked Before and After

OSPF and eBGP on five IOS XE routers from one vars file, with a parsed pre-check and post-check on every run. Includes a change that bounces every adjacency, the cli_parse retry bug that broke my first wait loop, and the route flap the checks couldn't see.

Lab diagram: Five IOS XE routers, OSPF area 0 across three HQ routers and eBGP from hq-edge1 to two branch routers, built and verified by one Ansible playbook. In HQ (OSPF area 0 / AS 65000),

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).

Lab diagram: Five IOS XE routers, OSPF area 0 across three HQ routers and eBGP from hq-edge1 to two branch routers, built and verified by one Ansible playbook. In HQ (OSPF area 0 / AS 65000), hq-core1 (Lo0 10.66.255.11) links to hq-core2 (Lo0 10.66.255.12) over Et0/1 to Et0/1 on 10.66.101.0/30 and to hq-edge1 (Lo0 10.66.255.13) over Et0/2 to Et0/1 on 10.66.102.0/30, both OSPF point-to-point. hq-edge1 runs eBGP over Et0/2 to br1-edge1 Et0/1 (AS 65001, Lo0 10.66.255.21) on 10.66.103.0/30, and over Et0/3 to br2-edge1 Et0/1 (AS 65002, Lo0 10.66.255.31) on 10.66.104.0/30. There is no redistribution, so the core routers don't learn the branch loopbacks.
OSPF area 0 inside HQ, eBGP from hq-edge1 to the two branches

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_interfaces uses replaced, 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. merged would 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 under router bgp. If you want the playbook to own the whole process, replaced or overridden is 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.

Lab diagram: One Ansible playbook run, left to right. vars/routing.yml holds loopbacks, p2p, ospf_links, bgp and an expect block that says what healthy looks like per router. A pre-check parses OSPF neighbors, the BGP summary and the RIB and keeps the pre-change picture. Six resource module tasks follow: ios_interfaces merged, ios_l3_interfaces replaced, ios_ospfv2 merged, ios_ospf_interfaces merged, ios_bgp_global merged and ios_bgp_address_family merged. A wait step uses ios_command with a regex count (FULL/ for OSPF, prefix-count rows for BGP). Then a post-check and an assert against expect and the pre-check: 0 lost. A highlighted blind spot between pre and post notes that br1 saw .11/.12 withdrawn and re-learned there, which snapshots can't see. The second run reported changed=0 and 0 new, 0 lost on all five routers.
One run: pre-check, six resource modules, wait, post-check, assert, and the gap between the snapshots

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.

Read next