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

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 withsearch('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.jsonto 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.