> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# OSPF and BGP with Ansible Resource Modules, Checked Before and After
- URL: https://www.pinglabz.com/ansible-ospf-bgp-ios/
- Published: 2026-11-01T00:42:40.000Z
- Updated: 2026-11-01T04:41:37.000Z
- Description: 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.
- Author: Jaime
- Tags: Network Automation, Cisco IOS XE, Cisco

This is part 13 of the [Ansible for network engineers series](https://www.pinglabz.com/ansible-network-automation/). [Part 12](https://www.pinglabz.com/ansible-parse-cli-textfsm-ttp-ios/) 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.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/ansible-ospf-bgp-ios-topology.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/ansible-ospf-bgp-ios-topology.png?ref=pinglabz.com)

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:

```yaml
# 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:

```yaml
# 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

```yaml
---
- 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:

```text
"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:

```text
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`:

```text
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:

```diff
@@ -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:

```text
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:

```text
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:

```text
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.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/ansible-ospf-bgp-ios-run-flow.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/ansible-ospf-bgp-ios-run-flow.png?ref=pinglabz.com)

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:

```yaml
---
- 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](https://www.pinglabz.com/ansible-rolling-change-serial-rollback-ios/) uses these checks to gate a rolling change with serial, with automatic rollback. The rest of the series is on the [series page](https://www.pinglabz.com/ansible-network-automation/).