A diagram drawn by hand is out of date the day after someone moves a cable. The routers already know who they are plugged into, through CDP and LLDP. This post reads both protocols on every device, turns the output into a list of links, compares that list with the links you meant to build, and writes a Markdown report with a Mermaid drawing of the network.
It was built in my home lab on six IOS XE lab devices, with ansible-core 2.21, cisco.ios 11.6, ansible.utils 6.1 and the ntc_templates parser from part 12 of the series. The previous post was part 14, rolling changes with serial and rollback.

Write down the links you meant to build
The map is only useful if there is something to compare it with. A small vars file lists every cable, one pair of ends per line. The order inside a pair does not matter, because the playbook sorts the two ends before it compares anything.
# vars/topology.yml: the links we intend to have, one entry per cable
intended_links:
- [hq-core1 Ethernet0/1, hq-core2 Ethernet0/1]
- [hq-core1 Ethernet0/2, hq-edge1 Ethernet0/1]
- [hq-edge1 Ethernet0/2, br1-edge1 Ethernet0/1]
- [hq-edge1 Ethernet0/3, br2-edge1 Ethernet0/1]
- [br1-edge1 Ethernet0/2, br1-acc1 Ethernet0/1]
mgmt_interface: Ethernet0/0
Ethernet0/0 on every device is the out of band management port. It sees a lot of neighbors that have nothing to do with this topology, so the playbook drops it.
The playbook
The first play runs on all devices. It makes sure LLDP is on (CDP is on by default on IOS, LLDP is not), parses show cdp neighbors detail and show lldp neighbors detail, and turns every neighbor into one string: local-device local-port|remote-device remote-port.
The second play runs on localhost. It sorts each pair, so hq-core1 Ethernet0/1 to hq-core2 Ethernet0/1 looks the same no matter which router reported it, and then compares the result with the intended list.
---
- name: Collect CDP and LLDP neighbors
hosts: sites
gather_facts: false
vars_files:
- ../vars/topology.yml
tasks:
- name: Make sure LLDP is running
cisco.ios.ios_lldp_global:
config:
enabled: true
state: merged
- name: Parse CDP neighbors
ansible.utils.cli_parse:
command: show cdp neighbors detail
parser:
name: ansible.netcommon.ntc_templates
set_fact: cdp
- name: Parse LLDP neighbors
ansible.utils.cli_parse:
command: show lldp neighbors detail
parser:
name: ansible.netcommon.ntc_templates
set_fact: lldp
- name: One "local|remote" string per neighbor, management port left out
ansible.builtin.set_fact:
cdp_seen: >-
{%- set out = [] -%}
{%- for n in cdp if n.local_interface != mgmt_interface -%}
{%- set _ = out.append(inventory_hostname ~ ' ' ~ n.local_interface ~ '|'
~ n.neighbor_name | regex_replace('\\..*$', '') ~ ' ' ~ n.neighbor_interface) -%}
{%- endfor -%}
{{ out }}
# LLDP gives short names (Et0/2), and neighbor_interface is the port
# description, so use neighbor_port_id and expand it
lldp_seen: >-
{%- set out = [] -%}
{%- for n in lldp -%}
{%- set local = n.local_interface | regex_replace('^Et(\\d)', 'Ethernet\\1') -%}
{%- if local != mgmt_interface -%}
{%- set _ = out.append(inventory_hostname ~ ' ' ~ local ~ '|'
~ n.neighbor_name | regex_replace('\\..*$', '') ~ ' '
~ n.neighbor_port_id | regex_replace('^Et(\\d)', 'Ethernet\\1')) -%}
{%- endif -%}
{%- endfor -%}
{{ out }}
- name: Build the map and compare it with the intended links
hosts: localhost
gather_facts: false
vars_files:
- ../vars/topology.yml
vars:
# only hosts that answered; a host that timed out is reported, not guessed
devices: "{{ groups['sites'] | select('in', hostvars | dict2items | selectattr('value.cdp_seen', 'defined') | map(attribute='key') | list) | list }}"
silent: "{{ groups['sites'] | difference(devices) }}"
# a link is the two ends sorted, so A|B and B|A are the same link
intended: "{{ intended_links | map('sort') | map('join', ' <-> ') | list | sort }}"
cdp_all: "{{ devices | map('extract', hostvars, 'cdp_seen') | flatten }}"
lldp_all: "{{ devices | map('extract', hostvars, 'lldp_seen') | flatten }}"
cdp_links: "{{ cdp_all | map('split', '|') | map('sort') | map('join', ' <-> ') | list }}"
lldp_links: "{{ lldp_all | map('split', '|') | map('sort') | map('join', ' <-> ') | list }}"
tasks:
- name: Write the report
ansible.builtin.template:
src: ../templates/neighbor-map.md.j2
dest: ../reports/neighbor-map.md
mode: "0644"
- name: Summary
ansible.builtin.debug:
msg:
- "intended {{ intended | length }}, seen by CDP {{ cdp_links | unique | length }}, by LLDP {{ lldp_links | unique | length }}"
- "no data from: {{ silent }}"
- "missing: {{ intended | difference(cdp_links | union(lldp_links)) }}"
- "unexpected: {{ (cdp_links | union(lldp_links)) | difference(intended) }}"
Two things in there came from getting it wrong first.
- The two parsers do not name things the same way. CDP gives
Ethernet0/2and a full domain name for the neighbor. LLDP givesEt0/2. Both get the domain cut off and the short name expanded. - In the LLDP template,
neighbor_interfaceis the neighbor's port description, not its port. My first run produced links to a port calledCORE: hq-core2 Et0/1. The port name is inneighbor_port_id.
The report template counts how many ends of each link reported it. A healthy link is 2/2. A link seen from one side only is 1/2, which usually means the protocol is off on the far end or a filter is dropping it.
# Neighbor map
Generated {{ now(utc=true).strftime('%Y-%m-%d %H:%M') }} UTC from CDP and LLDP on {{ devices | length }} devices.
{% if silent %}
No data from {{ silent | join(', ') }}. Links to these devices show up as 1/2 or missing.
{% endif %}
| Link | Intended | CDP | LLDP |
|---|---|---|---|
{% for l in (intended + cdp_links + lldp_links) | unique | sort %}
| {{ l }} | {{ 'yes' if l in intended else 'NO' }} | {{ cdp_links | select('equalto', l) | list | length }}/2 | {{ lldp_links | select('equalto', l) | list | length }}/2 |
{% endfor %}
`n/2` is how many of the two ends reported the neighbor. 1/2 means only one side sees the other.
```mermaid
graph LR
{% for l in (cdp_links + lldp_links) | unique | sort %}
{% set a = l.split(' <-> ')[0].split(' ') %}{% set b = l.split(' <-> ')[1].split(' ') %}
{{ a[0] | replace('-', '_') }}["{{ a[0] }}"] ---|"{{ a[1] | replace('Ethernet', 'Et') }} : {{ b[1] | replace('Ethernet', 'Et') }}"| {{ b[0] | replace('-', '_') }}["{{ b[0] }}"]
{% endfor %}
The `devices` variable only includes hosts that actually returned data. One of the branch devices in this lab times out now and then, and the first version of the report failed to render when that happened. Now a host that does not answer is listed under "no data from" instead of making its links look missing.
## A missing link
To show what a problem looks like, I shut `Ethernet0/2` on br1-edge1, the port that goes to the branch access switch, and waited a little over three minutes so the CDP and LLDP entries timed out. Then I ran the playbook.
```text
TASK [Summary] *****************************************************************
ok: [localhost] => {
"msg": [
"intended 5, seen by CDP 4, by LLDP 4",
"no data from: []",
"missing: ['br1-acc1 Ethernet0/1 <-> br1-edge1 Ethernet0/2']",
"unexpected: []"
]
}
PLAY RECAP *********************************************************************
br1-acc1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
br1-edge1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
br2-edge1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-core1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-core2 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-edge1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The report puts the gap in the table:
| Link | Intended | CDP | LLDP |
|---|---|---|---|
| br1-acc1 Ethernet0/1 <-> br1-edge1 Ethernet0/2 | yes | 0/2 | 0/2 |
| br1-edge1 Ethernet0/1 <-> hq-edge1 Ethernet0/2 | yes | 2/2 | 2/2 |
| br2-edge1 Ethernet0/1 <-> hq-edge1 Ethernet0/3 | yes | 2/2 | 2/2 |
| hq-core1 Ethernet0/1 <-> hq-core2 Ethernet0/1 | yes | 2/2 | 2/2 |
| hq-core1 Ethernet0/2 <-> hq-edge1 Ethernet0/1 | yes | 2/2 | 2/2 |
Both protocols agree, and neither end sees the other, which points at the link itself rather than at one protocol. When I started on this post I found this same port already shut. I brought it up before the first run, then shut it again on purpose for the test above.

Fixed
After no shutdown on the port and 90 seconds for the neighbors to come back:
TASK [Summary] *****************************************************************
ok: [localhost] => {
"msg": [
"intended 5, seen by CDP 5, by LLDP 5",
"no data from: []",
"missing: []",
"unexpected: []"
]
}
PLAY RECAP *********************************************************************
br1-acc1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
br1-edge1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
br2-edge1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-core1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-core2 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-edge1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The drawing at the end of the report, from the same run:
graph LR
br1_acc1["br1-acc1"] ---|"Et0/1 : Et0/2"| br1_edge1["br1-edge1"]
br1_edge1["br1-edge1"] ---|"Et0/1 : Et0/2"| hq_edge1["hq-edge1"]
br2_edge1["br2-edge1"] ---|"Et0/1 : Et0/3"| hq_edge1["hq-edge1"]
hq_core1["hq-core1"] ---|"Et0/1 : Et0/1"| hq_core2["hq-core2"]
hq_core1["hq-core1"] ---|"Et0/2 : Et0/1"| hq_edge1["hq-edge1"]
Paste that into anything that renders Mermaid (GitHub, many wikis, the Mermaid live editor) and you get a picture that matches the network as it is right now.
CDP or LLDP
On an all Cisco network they gave the same five links here. CDP is on by default and has a little more detail on Cisco gear. LLDP is the standard one, so it is the one that sees servers, phones from other vendors and non Cisco switches. Running both costs very little, and when they disagree that is worth looking at too.
Where to take it
- Run it on a schedule and send the report only when
missingorunexpectedis not empty. - Build
intended_linksfrom your source of truth (NetBox cables, for example) instead of a hand written file. - Add the interface descriptions to the check, so a port described as going to one device but plugged into another gets flagged.
Part 16 Next in the series is [part 16, spanning tree root, portfast and LACP EtherChannel](https://www.pinglabz.com/ansible-stp-etherchannel-lacp-ios/). The rest of the series is on the series page.