Build a Network Inventory from Ansible Facts and Parsed CLI Output

One read-only playbook turns facts and parsed show commands from six IOS XE devices into an inventory file and page: hostname, image, interfaces, CDP neighbors, OSPF and BGP state. Plus the CDP noise from the management segment and how I filtered it.

Lab diagram: the control node reaches six devices over SSH: five routers (hq-core1, hq-core2, hq-edge1, br1-edge1, br2-edge1) and the access switch br1-acc1. For each device it collects ios_f

Every network team eventually asks the same questions: what is this box running, what is plugged into it, and who are its neighbors. The answers are all on the devices. The problem is that they're spread across five show commands per box, and the spreadsheet someone made last year is wrong.

This post builds the inventory from the devices themselves. One read-only Ansible playbook gathers facts, parses four show commands into structured data, and writes three files: JSON for other tools, Markdown for Git, and a small HTML page for people.

I built this in my home lab against the same six IOS XE devices as the rest of the series. Everything below is real output from two runs.

It uses the parsing approach from part 9 on TextFSM and TTP and the report pattern from part 22's nightly audit.

Versions: ansible-core 2.21, cisco.ios 11.6, ansible.utils with ntc-templates 9.3.0 and textfsm 2.1.0, IOS XE 17.18.2 on five routers and one L2 switch image (br1-acc1).

Lab diagram: the control node reaches six devices over SSH: five routers (hq-core1, hq-core2, hq-edge1, br1-edge1, br2-edge1) and the access switch br1-acc1. For each device it collects ios_facts (hostname, version, image and serial) and four show commands: show ip interface brief, show cdp neighbors detail, show ip ospf neighbor and show ip bgp summary. cli_parse with ntc-templates turns the show output into structured rows. A filter step then drops CDP neighbours seen on the management interface Ethernet0/0. Before the filter each device reported 10 to 12 CDP neighbours, because they share a management segment; afterwards each reports 1 to 3. The result is a per-host inv record (hostname, version, image, serial, mgmt_ip, interfaces, neighbors, ospf, bgp), and the control node writes inventory.json for diffs, inventory.md for Git and inventory.html for people: 6 devices written.
Six devices in, one inventory out. Filtering the management interface took CDP neighbour counts from 10-12 down to 1-3.

Where each field comes from

Field Source
hostname, version, image, serial ios_facts, default subset
interfaces and addresses show ip interface brief, parsed
neighbors show cdp neighbors detail, parsed
OSPF state show ip ospf neighbor, parsed
BGP state show ip bgp summary, parsed

Facts cover the identity of the box. For everything else I used ansible.utils.cli_parse with the ntc_templates parser, because those templates already exist for all four commands and return the same field names on every device. Writing regexes for show cdp neighbors detail yourself is a good way to lose an afternoon.

The playbook

# playbooks/230-facts-inventory.yml
---
# Build a network inventory from facts and parsed show output. Read-only on the devices.
- name: Gather and parse
  hosts: sites
  gather_facts: false
  vars:
    inv_mgmt_intf: Ethernet0/0   # CDP on the shared management segment sees every box; leave it out
  tasks:
    - name: Facts
      cisco.ios.ios_facts:
        gather_subset: [default, interfaces]

    - name: Parse show commands with ntc-templates
      ansible.utils.cli_parse:
        command: "{{ item }}"
        parser:
          name: ansible.netcommon.ntc_templates
      loop:
        - show ip interface brief
        - show cdp neighbors detail
        - show ip ospf neighbor
        - show ip bgp summary
      register: parsed

    - name: One record per device
      ansible.builtin.set_fact:
        inv:
          hostname: "{{ ansible_net_hostname }}"
          version: "{{ ansible_net_version }}"
          image: "{{ ansible_net_image }}"
          serial: "{{ ansible_net_serialnum }}"
          mgmt_ip: "{{ ansible_host }}"
          interfaces: "{{ p[0] | map('dict2items') | map('items2dict') | list
                          | map('ansible.utils.keep_keys', target=['interface', 'ip_address', 'status', 'proto']) | list }}"
          neighbors: "{{ p[1] | rejectattr('local_interface', 'eq', inv_mgmt_intf) | map('ansible.utils.keep_keys', target=['neighbor_name', 'local_interface', 'neighbor_interface', 'mgmt_address']) | list }}"
          ospf: "{{ p[2] | map('ansible.utils.keep_keys', target=['neighbor_id', 'state', 'interface']) | list }}"
          bgp: "{{ p[3] | map('ansible.utils.keep_keys', target=['bgp_neighbor', 'neighbor_as', 'state_or_prefixes_received']) | list }}"
      vars:
        p: "{{ parsed.results | map(attribute='parsed') | list }}"

- name: Write the inventory
  hosts: localhost
  gather_facts: false
  vars:
    out_dir: "{{ playbook_dir }}/../reports/inventory"
    devices: "{{ groups['sites'] | sort | select('in', hostvars | dict2items | selectattr('value.inv', 'defined') | map(attribute='key') | list) | list }}"
    built: "{{ now(utc=true).strftime('%Y-%m-%d %H:%M UTC') }}"
  tasks:
    - name: Folder
      ansible.builtin.file:
        path: "{{ out_dir }}"
        state: directory
        mode: "0755"

    - name: Machine-readable copy
      ansible.builtin.copy:
        dest: "{{ out_dir }}/inventory.json"
        content: "{{ dict(devices | zip(devices | map('extract', hostvars, 'inv'))) | to_nice_json }}"
        mode: "0644"

    - name: Human-readable pages
      ansible.builtin.template:
        src: "../templates/inventory.{{ item }}.j2"
        dest: "{{ out_dir }}/inventory.{{ item }}"
        mode: "0644"
      loop: [md, html]

    - name: Summary
      ansible.builtin.debug:
        msg: "{{ devices | length }} devices written to reports/inventory/"

The shape is the same as part 22. The first play builds one inv record per device with only the fields I want, using ansible.utils.keep_keys to drop the dozens of other columns the templates return. The second play runs on the control node and writes the files from hostvars.

The JSON is written with to_nice_json from a dict keyed by device name, so a script or another playbook can load it and look up hq-edge1 directly.

The CDP surprise

The first run looked fine until I read the neighbor counts:

| Device | Version | Image | Mgmt | Up interfaces | CDP neighbors | OSPF FULL | BGP up |
|---|---|---|---|---|---|---|---|
| br1-acc1 | 17.18.2 | unix:/iol/iol.bin | 10.66.10.22 | 5 | 10 | 0 | 0 |
| br1-edge1 | 17.18.2 | unix:/iol/iol.bin | 10.66.10.21 | 4 | 11 | 0 | 1 |
| hq-edge1 | 17.18.2 | unix:/iol/iol.bin | 10.66.10.13 | 5 | 12 | 1 | 2 |

Ten neighbors on an access switch with one uplink. The detail showed why:

Neighbors (CDP):
- Ethernet0/1 -> br1-edge1 Ethernet0/2
- Ethernet0/0 -> br1-edge1 Ethernet0/0
- Ethernet0/0 -> br2-edge1 Ethernet0/0
- Ethernet0/0 -> hq-core1 Ethernet0/0
- Ethernet0/0 -> hq-core2 Ethernet0/0
- Ethernet0/0 -> hq-dist1 Ethernet0/0
- Ethernet0/0 -> hq-edge1 Ethernet0/0
- Ethernet0/0 -> rogue-r1 Ethernet0/0
...

Ethernet0/0 is the management interface, and every device in the lab sits on one shared management segment. CDP is on by default, so each box sees every other box through it, including lab devices from earlier posts that aren't in this inventory. The data was correct, but useless as a topology.

There are two fixes. On a real network, turn CDP off on the management interface (no cdp enable under it), because announcing your platform and version to everything on that segment is exactly what part 20's hardening was meant to stop. In the playbook, I filter it out with rejectattr('local_interface', 'eq', inv_mgmt_intf), which is the inv_mgmt_intf var at the top. I didn't change CDP on the lab devices for this post.

The result

After the filter, run twice:

br1-acc1   : ok=3  changed=0  unreachable=0  failed=0
...
hq-edge1   : ok=3  changed=0  unreachable=0  failed=0
localhost  : ok=4  changed=2  unreachable=0  failed=0

br1-acc1   : ok=3  changed=0  unreachable=0  failed=0
...
hq-edge1   : ok=3  changed=0  unreachable=0  failed=0
localhost  : ok=4  changed=0  unreachable=0  failed=0

No device ever reports a change. The second run also shows changed=0 on the control node, but only because both runs fell in the same minute, so the Built line was identical. Normally the timestamp makes the Markdown and HTML change every run while the JSON stays the same unless the network does. That makes the JSON the file to diff.

The Markdown:

# Network inventory

Built 2026-11-01 04:38 UTC from device facts and parsed show output. 6 devices.

| Device | Version | Image | Mgmt | Up interfaces | CDP neighbors | OSPF FULL | BGP up |
|---|---|---|---|---|---|---|---|
| br1-acc1 | 17.18.2 | unix:/iol/iol.bin | 10.66.10.22 | 5 | 1 | 0 | 0 |
| br1-edge1 | 17.18.2 | unix:/iol/iol.bin | 10.66.10.21 | 4 | 2 | 0 | 1 |
| br2-edge1 | 17.18.2 | unix:/iol/iol.bin | 10.66.10.31 | 3 | 1 | 0 | 1 |
| hq-core1 | 17.18.2 | unix:/iol/iol.bin | 10.66.10.11 | 4 | 2 | 2 | 0 |
| hq-core2 | 17.18.2 | unix:/iol/iol.bin | 10.66.10.12 | 3 | 1 | 1 | 0 |
| hq-edge1 | 17.18.2 | unix:/iol/iol.bin | 10.66.10.13 | 5 | 3 | 1 | 2 |

## br1-acc1

Interfaces with an address:
- Ethernet0/0 10.66.10.22 (up/up)
- Loopback0 10.66.255.22 (up/up)

Neighbors (CDP):
- Ethernet0/1 -> br1-edge1 Ethernet0/2

## br1-edge1

Interfaces with an address:
- Ethernet0/0 10.66.10.21 (up/up)
- Ethernet0/1 10.66.103.2 (up/up)
- Loopback0 10.66.255.21 (up/up)

Neighbors (CDP):
- Ethernet0/1 -> hq-edge1 Ethernet0/2
- Ethernet0/2 -> br1-acc1 Ethernet0/1
BGP: 10.66.103.1 AS 65000 (4)

## br2-edge1

Interfaces with an address:
- Ethernet0/0 10.66.10.31 (up/up)
- Ethernet0/1 10.66.104.2 (up/up)
- Loopback0 10.66.255.31 (up/up)

Neighbors (CDP):
- Ethernet0/1 -> hq-edge1 Ethernet0/3
BGP: 10.66.104.1 AS 65000 (4)

## hq-core1

Interfaces with an address:
- Ethernet0/0 10.66.10.11 (up/up)
- Ethernet0/1 10.66.101.1 (up/up)
- Ethernet0/2 10.66.102.1 (up/up)
- Loopback0 10.66.255.11 (up/up)

Neighbors (CDP):
- Ethernet0/1 -> hq-core2 Ethernet0/1
- Ethernet0/2 -> hq-edge1 Ethernet0/1

OSPF: 10.66.255.12 FULL/  - on Ethernet0/1; 10.66.255.13 FULL/  - on Ethernet0/2

## hq-core2

Interfaces with an address:
- Ethernet0/0 10.66.10.12 (up/up)
- Ethernet0/1 10.66.101.2 (up/up)
- Loopback0 10.66.255.12 (up/up)

Neighbors (CDP):
- Ethernet0/1 -> hq-core1 Ethernet0/1

OSPF: 10.66.255.11 FULL/  - on Ethernet0/1

## hq-edge1

Interfaces with an address:
- Ethernet0/0 10.66.10.13 (up/up)
- Ethernet0/1 10.66.102.2 (up/up)
- Ethernet0/2 10.66.103.1 (up/up)
- Ethernet0/3 10.66.104.1 (up/up)
- Loopback0 10.66.255.13 (up/up)

Neighbors (CDP):
- Ethernet0/2 -> br1-edge1 Ethernet0/1
- Ethernet0/3 -> br2-edge1 Ethernet0/1
- Ethernet0/1 -> hq-core1 Ethernet0/2

OSPF: 10.66.255.11 FULL/  - on Ethernet0/1
BGP: 10.66.103.2 AS 65001 (1); 10.66.104.2 AS 65002 (1)

That matches the topology the series has built: hq-core1 and hq-core2 in OSPF, hq-core1 to hq-edge1 in OSPF, and hq-edge1 running eBGP to the two branch edges in AS 65001 and 65002. The (4) and (1) after a BGP peer are prefixes received, which is how show ip bgp summary reports an established session.

And one device's record from inventory.json:

{
  "bgp": [
    {
      "bgp_neighbor": "10.66.103.2",
      "neighbor_as": "65001",
      "state_or_prefixes_received": "1"
    },
    {
      "bgp_neighbor": "10.66.104.2",
      "neighbor_as": "65002",
      "state_or_prefixes_received": "1"
    }
  ],
  "hostname": "hq-edge1",
  "image": "unix:/iol/iol.bin",
  "interfaces": [
    {
      "interface": "Ethernet0/0",
      "ip_address": "10.66.10.13",
      "proto": "up",
      "status": "up"
    },
    {
      "interface": "Ethernet0/1",
      "ip_address": "10.66.102.2",
      "proto": "up",
      "status": "up"
    },
    {
      "interface": "Ethernet0/2",
      "ip_address": "10.66.103.1",
      "proto": "up",
      "status": "up"
    },
    {
      "interface": "Ethernet0/3",
      "ip_address": "10.66.104.1",
      "proto": "up",
      "status": "up"
    },
    {
      "interface": "Loopback0",
      "ip_address": "10.66.255.13",
      "proto": "up",
      "status": "up"
    }
  ],
  "mgmt_ip": "10.66.10.13",
  "neighbors": [
    {
      "local_interface": "Ethernet0/2",
      "mgmt_address": "10.66.103.2",
      "neighbor_interface": "Ethernet0/1",
      "neighbor_name": "br1-edge1.lab.example.net"
    },
    {
      "local_interface": "Ethernet0/3",
      "mgmt_address": "10.66.104.2",
      "neighbor_interface": "Ethernet0/1",
      "neighbor_name": "br2-edge1.lab.example.net"
    },
    {
      "local_interface": "Ethernet0/1",
      "mgmt_address": "10.66.102.1",
      "neighbor_interface": "Ethernet0/2",
      "neighbor_name": "hq-core1.lab.example.net"
    }
  ],
  "ospf": [
    {
      "interface": "Ethernet0/1",
      "neighbor_id": "10.66.255.11",
      "state": "FULL/  -"
    }
  ],
  "serial": "6",
  "version": "17.18.2"
}

A few things worth knowing about the parsed data:

  • The OSPF state comes back as FULL/ -, with the DR role after the slash. These are point-to-point links, so there is no DR. Match with search('FULL'), not equality.
  • The BGP template puts either a state name (Idle, Active) or a prefix count in the same field, state_or_prefixes_received. A number means the session is up, which is why the summary counts values matching ^\d+$.
  • CDP returns the neighbor's full domain name. The templates strip it with regex_replace('\..*$', '') for display, and the JSON keeps it.
  • The serial numbers are 1 to 6 because that's what the lab image reports. Real hardware gives the chassis serial here.

The templates

The Markdown template, which is what you'd commit:

# Network inventory

Built {{ built }} from device facts and parsed show output. {{ devices | length }} devices.

| Device | Version | Image | Mgmt | Up interfaces | CDP neighbors | OSPF FULL | BGP up |
|---|---|---|---|---|---|---|---|
{% for h in devices %}{% set d = hostvars[h].inv %}
| {{ d.hostname }} | {{ d.version }} | {{ d.image }} | {{ d.mgmt_ip }} | {{ d.interfaces | selectattr('status', 'eq', 'up') | list | length }} | {{ d.neighbors | length }} | {{ d.ospf | selectattr('state', 'search', 'FULL') | list | length }} | {{ d.bgp | selectattr('state_or_prefixes_received', 'match', '^\d+$') | list | length }} |
{% endfor %}
{% for h in devices %}{% set d = hostvars[h].inv %}

## {{ d.hostname }}

Interfaces with an address:
{% for i in d.interfaces if i.ip_address != 'unassigned' %}
- {{ i.interface }} {{ i.ip_address }} ({{ i.status }}/{{ i.proto }})
{% endfor %}

Neighbors (CDP):
{% for n in d.neighbors %}
- {{ n.local_interface }} -> {{ n.neighbor_name | regex_replace('\..*$', '') }} {{ n.neighbor_interface }}
{% else %}
- none
{% endfor %}
{% if d.ospf %}

OSPF: {% for o in d.ospf %}{{ o.neighbor_id }} {{ o.state }} on {{ o.interface }}{{ '; ' if not loop.last }}{% endfor %}

{% endif %}
{% if d.bgp %}
BGP: {% for b in d.bgp %}{{ b.bgp_neighbor }} AS {{ b.neighbor_as }} ({{ b.state_or_prefixes_received }}){{ '; ' if not loop.last }}{% endfor %}

{% endif %}
{% endfor %}

The HTML version is one section per device with an interface table and a neighbor table. No external assets, so it opens from a file share:

<!doctype html>
<html><head><meta charset="utf-8"><title>Network inventory</title>
<style>body{font-family:sans-serif;margin:2em}table{border-collapse:collapse;margin-bottom:1em}td,th{border:1px solid #ccc;padding:4px 8px;text-align:left}</style>
</head><body>
<h1>Network inventory</h1>
<p>Built {{ built }}. {{ devices | length }} devices.</p>
{% for h in devices %}{% set d = hostvars[h].inv %}
<h2>{{ d.hostname }}</h2>
<p>{{ d.version }}, {{ d.image }}, management {{ d.mgmt_ip }}</p>
<table><tr><th>Interface</th><th>Address</th><th>Status</th></tr>
{% for i in d.interfaces %}<tr><td>{{ i.interface }}</td><td>{{ i.ip_address }}</td><td>{{ i.status }}/{{ i.proto }}</td></tr>
{% endfor %}</table>
<table><tr><th>Local</th><th>Neighbor</th><th>Remote port</th></tr>
{% for n in d.neighbors %}<tr><td>{{ n.local_interface }}</td><td>{{ n.neighbor_name | regex_replace('\..*$', '') }}</td><td>{{ n.neighbor_interface }}</td></tr>
{% endfor %}</table>
{% if d.ospf %}<p>OSPF: {% for o in d.ospf %}{{ o.neighbor_id }} {{ o.state }}{{ ', ' if not loop.last }}{% endfor %}</p>{% endif %}
{% if d.bgp %}<p>BGP: {% for b in d.bgp %}{{ b.bgp_neighbor }} AS {{ b.neighbor_as }} ({{ b.state_or_prefixes_received }}){{ ', ' if not loop.last }}{% endfor %}</p>{% endif %}
{% endfor %}
</body></html>

What I'd add next

  • Commit inventory.json to Git after each run. A diff then shows a new neighbor, a lost BGP peer or a changed image line by line. That's the next post in the series.
  • Cross-check CDP both ways. Every link should appear from both ends; a one-sided entry is a cabling or CDP config problem.
  • Run it from the same timer as part 22's audit if you want it nightly. I didn't schedule it here; there is no cron entry or timer on the lab for it.

Part 24 covers a Git pipeline for network configs: a CI gate with yamllint, ansible-lint and a check-mode run before deploy. The rest of the series is on the series page.

Read next