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

# Build a CDP and LLDP Neighbor Map with Ansible on Cisco IOS XE
- URL: https://www.pinglabz.com/ansible-cdp-lldp-neighbor-map-ios/
- Published: 2026-11-01T01:21:33.000Z
- Updated: 2026-11-01T04:41:40.000Z
- Description: Parse CDP and LLDP neighbors on every device, normalize the names, compare the links with an intended list and render a Markdown report plus a Mermaid drawing. A shut port shows up as a missing link, then clears after the fix.
- Author: Jaime
- Tags: Network Automation, Cisco IOS XE, Cisco

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](https://www.pinglabz.com/ansible-parse-cli-textfsm-ttp-ios/) of the series. The previous post was [part 14, rolling changes with serial and rollback](https://www.pinglabz.com/ansible-rolling-change-serial-rollback-ios/).

[![Lab diagram: Six IOS XE devices (hq-core1, hq-core2, hq-edge1, br1-edge1, the br1-acc1 switch and br2-edge1) each send parsed CDP and LLDP neighbor data into an Ansible play. The play parses with ntc_templates, normalizes names and drops the Et0/0 management port, sorts both ends of each link and removes duplicates, compares the result with intended_links from vars/topology.yml, and writes neighbor-map.md with a link table and a Mermaid drawing.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-2.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-2.png?ref=pinglabz.com)

Figure 1: CDP and LLDP from six devices, parsed, normalized and compared with the intended links.

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

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

```yaml
---
- 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/2` and a full domain name for the neighbor. LLDP gives `Et0/2`. Both get the domain cut off and the short name expanded.
- In the LLDP template, `neighbor_interface` is the neighbor's port description, not its port. My first run produced links to a port called `CORE: hq-core2 Et0/1`. The port name is in `neighbor_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.

```jinja
# 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.

[![Lab diagram: The five intended links across HQ, Branch 1 and Branch 2: hq-core1 Et0/1 to hq-core2 Et0/1, hq-core1 Et0/2 to hq-edge1 Et0/1, hq-edge1 Et0/2 to br1-edge1 Et0/1, hq-edge1 Et0/3 to br2-edge1 Et0/1, and br1-edge1 Et0/2 to the br1-acc1 switch Et0/1. That last link is marked missing, seen 0/2 by CDP and 0/2 by LLDP while the port was shut, and 2/2 again after no shutdown.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-3.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-3.png?ref=pinglabz.com)

The five intended links. br1-edge1 Et0/2 to br1-acc1 was 0/2 on both protocols while the port was shut.

## Fixed

After `no shutdown` on the port and 90 seconds for the neighbors to come back:

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

```mermaid
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 `missing` or `unexpected` is not empty.
- Build `intended_links` from 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](https://www.pinglabz.com/ansible-stp-etherchannel-lacp-ios/) 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](https://www.pinglabz.com/ansible-network-automation/).