12.05.2020       Выпуск 334 (11.05.2020 - 17.05.2020)       Статьи

Визуализация сетевых топологий, или зачем еще сетевому инженеру Python #2

Эта статья написана по мотивам решения задания на недавно прошедшем онлайн-марафоне DevNet от Cisco. Участникам предлагалось автоматизировать анализ и визуализацию произвольной сетевой топологии и, опционально, происходящих в ней изменений.

 

Задача является не самой тривиальной, и в блогосфере встречается довольно мало статей на эту тему. Ниже представляю разбор собственной реализации, а также описание используемых инструментов и подходов.

Читать>>




Экспериментальная функция:

Ниже вы видите текст статьи по ссылке. По нему можно быстро понять ссылка достойна прочтения или нет

Просим обратить внимание, что текст по ссылке и здесь может не совпадать.

Привет, Хабр! Эта статья написана по мотивам решения задания на недавно прошедшем онлайн-марафоне DevNet от Cisco. Участникам предлагалось автоматизировать анализ и визуализацию произвольной сетевой топологии и, опционально, происходящих в ней изменений.

Задача является не самой тривиальной, и в блогосфере встречается довольно мало статей на эту тему. Ниже представляю разбор собственной реализации, а также описание используемых инструментов и подходов.

Всем заинтересовавшимся добро пожаловать под кат!


немного Javascript.

Дисклеймер

Данная статья не претендует на всеобщий охват вариантов и проблематики визуализации сетей, но при описании частного случая руководствуется и общими соображениями.
Все нижесказанное и вышесказанное является личным оценочным суждением автора, если не указано обратное.
Приведенный код распространяется под лицензией MIT и не дает гарантий никакого рода.

Обсуждение и конструктивная критика всячески приветствуются.

Если вы заметили опечатку, пожалуйста, воспользуйтесь комбинацией Ctrl+Enter или ⌘+Enter для ее отправки автору.





Постановка задачи

В исходном виде задание выглядело следующим образом:

Имеется сеть, состоящая из различных L2/L3 сетевых устройств под управлением IOS/IOSXE. Известен список IP-адресов управления для всех устройств, все устройства доступны по IP, для каждого устройства есть доступ для выполнения show-команд. Для вас доступны любые способы сбора информации, но поверьте, вам вряд ли нужен SNMP. Хотя мы не в праве ограничивать вашу фантазию.

Основная задача: определить физическую топологию соединений устройств по LLDP и визуализировать ее в удобном для восприятия человеком виде (да, нам всем удобны графические схемы). Выбрать формат хранения данных о топологии в удобном для машинного анализа виде (да, машинам неудобны графические схемы).

На рисунке c топологией должны быть отображены:
• Пиктограмма каждого устройства (коммутаторы и маршрутизаторы могут быть отмечены одинаковым типом пиктограммы).
• Hostname устройства.
• Название каждого интерфейса (можно в сокращённом формате, например, вместо GigabitEthernet0/0 — G0/0).

Допускается реализация фильтров, ограничивающих (скрывающих) информацию.
Дополнительная задача: определить изменения в топологии (сравнив текущую и предыдущую версии) и визуализировать их в удобном для восприятия человеком виде.









На входе — IP-адреса и учетные данные для доступа на оборудование, на выходе — готовая топология. Огромное пространство для экспериментов и вариантов где-то посередине.

Для себя дополнительно ввел следующие директивы для выбора инструментов реализации:

  • Функциональность vs простота.
    Должен быть соблюден баланс между функциональностью решения и его простотой. Для прототипа можно задействовать часть готовых свободно распространяемых решений.
  • Наличие опыта работы с наибольшей частью инструментов.
    На реализацию было трое суток, одни из которых у меня выпали по неотложным делам. Чтобы уложиться в сроки и сделать полнофункциональное решение, желательно было выбрать какую-то часть уже знакомых фреймворков.
  • Возможность переиспользования решения.
    Задача вполне применима ко многим продакшнам, стоит это учесть.
  • Мультиплатформенность.
    Как стоит учесть и наличие разных платформ в реальности.
  • Наличие документации к выбранным инструментам в свободном доступе.
    Необходимое требование к любому неодноразовому решению.

Готовые продукты

Перед изобретением велосипеда всегда стоит поинтересоваться, не решена ли задача до нас. Вот в этой статье на Хабре довольно хорошо расписаны существующие продукты для построения сетевых карт. Ожидаемо, ничего напрямую подходящего, а модули визуализации зачастую идут как составная часть большой системы мониторинга, что значительно уменьшает возможные сценарии переиспользования и кастомизации.

Декомпозиция задачи и выбор инструментов

Абстрактную задачу на автоматизированную визуализацию сетевых топологий можно разделить примерно на следующие уровни и этапы:
high_level

Пройдемся по ним снизу вверх с оглядкой на имеющиеся требования:

  1. Сетевое оборудование.
    По условиям нужно реализовать поддержку IOS и IOS-XE.
    Но в реальности может быть зоопарк намного более гетерогенная сеть. Постараемся это учесть.



  2. Источники данных о топологии.
    В задании предлагается использовать протокол LLDP (Link Layer Discovery Protocol), работающий, как следует из названия, на канальном уровне (L2) в модели OSI. Это стандартизированный протокол, описанный в IEEE 802.1AB. Поддерживается большинством производителей сетевого оборудования и системами на Linux и Windows, что нам подходит.
    Потенциально информация о топологии может также быть обогащена информацией из специфических для устройств выводов таблиц маршрутизации, коммутации, протоколов маршрутизации и т.д. Оставим это на будущее.



  3. Протоколы и интерфейсы доступа.
    Наиболее новые устройства и платформы поддерживают красивые и модные NETCONF, REST API, да RESTCONF с YANG моделями и структурами данных. Но наличие легаси диктует необходимость использования SSH, Telnet и стандартного CLI.
  4. Протокол- и вендор-специфичные драйверы/плагины.
    Как и обещает заголовок статьи, основная часть логики будет написана на Python, т.к. он обладает развитой экосистемой фреймворков для работы с сетевым оборудованием, и с ним у меня имеется наибольший опыт.
    Для работы с API устройств может использоваться стандартный модуль requests либо специализированные сторонние модули.
    Для доступа к оборудованию через SSH/Telnet могут быть использованы фреймворки netmiko, scrapli, paramiko. Они позволяют эмулировать CLI из Python — т.е. отправлять на оборудование команды и получать в ответ на них, как правило, текстовый вывод той или иной степени форматированности и предсказуемости.
    Также существует некоторое количество более высокоуровневых сетевых фреймворков, реализующих дополнительные возможности над уже упомянутым инструментарием. К их числу можно отнести NAPALM и Nornir. NAPALM предоставляет вендор-нейтральные GETTER'ы для получения определенных типов данных с оборудования, включая LLDP. Nornir же реализует дополнительные инструменты для удобства и многопоточности из коробки.
    SNMP оставим более традиционные для него задачи мониторинга.




  5. Неструктурированные данные -> Инструменты нормализации данных -> Структурированные данные.
    Доступ через API обычно позволяет получить уже структурированный вывод, но вот текстовый вывод, получаемый через CLI от сетевых устройств является непригодным для прямой обработки. Для извлечения полезных данных традиционно используется стандартный модуль re и регулярные выражения. Более новым подходом является фреймворк TextFSM от Google с более удобными для использования шаблонами.
    Уже упомянутый выше NAPALM для поддерживаемых GETTER'ов реализует всю эту обработу внутри себя и на выходе отдает уже форматированный вывод, что позволяет облегчить задачу.

  6. Обработка данных Представление топологии в структурах данных.
    Имея на данные о топологии со всех устройств, остается привести их к общему виду, проанализировать и собрать итоговый пазл.
  7. Представление топологии в формате инструмента визуализации.
    В зависимости от выбора инструмента для визуализации может потребоваться дополнительное преобразование итоговой топологии в формат данных, принимаемый им на вход.
  8. Движок визуализации
    Самый неочевидный для меня пункт, раньше подобного опыта собственной разработки не было. Изучение гугла и советы коллег наметили потенциальный список фреймворков под Python (pygraphviz, matplotlib, networkx) и фреймворки под JS D3.js, vis.js. А в собственных заметках на полях нашелся JS+HTML5 Toolkit NeXt UI, виденный ранее на просторах лаб Cisco DevNet и разработанный ими же. Он неплохо документирован, заточен на визуализацию сетей и умеет многое из коробки.
  9. Визуализированная топология
    Наша конечная цель. Может представлять из себя как статическое изображение или HTML-документ, так и что-то более продвинутое и интерактивное.

Суммируя, далеко не полный список вариантов:
detailed

В итоге выбор пал на следующий стек:

  • LLDP как источник информации о топологии.
  • SSH для доступа на оборудование.
  • Nornir для многопоточности, удобства обработки результатов и организации данных об оборудовании в inventory.
  • NAPALM для абстрагирования от задач ручного парсинга CLI.
  • Python3 для написания основной логики.
  • NeXt UI (JS+HTML5) для визуализации полученного через Python результата.

NAPALM и Nornir до этого уже доводилось вполне успешно использовать для задач сетевого аудита со сбором различных данных с сотен стройств. NAPALM из коробки умеет в LLDP на Cisco IOS/IOSXE, IOS-XR, NX-OS, Juniper JunOS и Arista EOS.
К тому же, с учетом задуманного разделения логики выше, дополнительные источники данных и коннекторы к ним могут быть добавлены параллельно и учтены при дальнейшем сведении и обработке данных.
С Next UI же предстояло разобраться на ходу, но уж больно интересно выглядели примеры.

Предварительная подготовка

Тестовый стенд с оборудованием

В качестве тестового стенда использовался эмулятор Cisco Modeling Labs. Это новая версия эмулятора VIRL. В Cisco DevNet Sandbox можно получить бесплатный доступ к лабе с ним, предварительно зарезервировав время (в пару кликов) и настроив VPN-доступ (через AnyConnect). А когда-то единственными вариантами были железо дома или продакшн приключения с GNS3. :)

Вид тестовой топологии в интерфейсе CML, на выходе должно получиться что-то похожее:

Имеются устройства на IOS (edge-sw01), IOSXE (internet-rtr01, distr-rtr01, distr-rtr02), NXOS (dist-sw01, dist-sw02), IOSXR (core-rtr01, core-rtr02) и ASA (edge-firewall01). На всех коммутаторах и маршрутизаторах включен LLDP. Доступ по SSH включен на IOS, IOSXE и NXOS нодах.

Установка и инициализация Nornir

Nornir является сторонним Python-фреймворком. Распространяется через PyPI, требует Python версии 3.6.2 и выше. За собой тянет вереницу зависимостей, включая NAPALM и netmiko. При установке не на чистую систему рекомендуется использовать виртуальное окружение Python (venv) для изоляции зависимостей. Тестирование и разработка велись на MacOS, но Linux-дистрибутивы и Windows тоже должны поддерживаться.

$ mkdir ~/testenv
$ python3.7 -m venv ~/testenv/
$ source ~/testenv/bin/activate
(testenv)$ pip install nornir

Nornir поддерживает различные варианты реализации inventory для систематизации информации об устройствах и параметрах доступа на них.
В этом примере остановимся на его стандартном модуле SimpleInventory.
Общие настройки Nornir хранятся в yaml файле, имя может быть произвольным, но нужно будет указать его при дальнейшей инициализации в Python-скрипте.
nornir_config.yaml:


---
core:
    num_workers: 20
inventory:
    plugin: nornir.plugins.inventory.simple.SimpleInventory
    options:
        host_file: "inventory/hosts_devnet_sb_cml.yml"
        group_file: "inventory/groups.yml"

Как видно в примере выше, в опциях определены еще два yaml-файла: файл хостов и групп. В первом хранится информация об индивидуальных хостах и их свойствах. Во втором — список групп и их свойств. Хост может быть отнесен к одной или более групп и наследует все их свойства, что уменьшает размер конфигурации. Имена файлов могут быть произвольными, но тоже должны совпадать с указанными в освновном конфигурационном файле.
Параметр num_workers указывает Nornir количество потоков, в которое дожно происходить взаимодействие с сетевым оборудованием. По умолчанию 20.

inventory/hosts_devnet_sb_cml.yml имеет общий вид:


---

internet-rtr01:
    hostname: 10.10.20.181
    platform: ios
    groups:
        - devnet-cml-lab

dist-sw01:
    hostname: 10.10.20.177
    platform: nxos_ssh
    transport: ssh
    groups:
        - devnet-cml-lab

Для примера указаны два хоста. В них заданы IP-адреса и тип платформы, используемый в сумме с транспортом (для IOS по умолчанию SSH) для правильного выбора Норниром и его плагинами коннектора к оборудованию. Оба хоста включены в группу 'devnet-cml-lab'.

В groups.yml определим групповые настройки для них:

---

devnet-cml-lab:
    username: cisco
    password: cisco
    connection_options:
        napalm:
            extras:
                optional_args:
                    secret: cisco

Выше заданы используемые логин, пароль и пароль на enable режим для оборудования Cisco. Они будут унаследованы всеми членами группы.
Важно! Никогда не делайте так в продакшне и не храните пароли и логины в открытом виде, настройки приведены для демонстрации.
Это базовые настройки, далее необходимо инициализировать Nornir в Python-скрипте и начать работу с ним.

Скачивание NeXt UI

Для локального использования и тестирования достаточно скачать исходники с GitHub, что мы и сделаем. Его компоненты будут лежать в ./next_sources.


И предварительно имеем:

$ tree . -L 2
.
├── inventory
│   ├── groups.yml
│   └── hosts_devnet_sb_cml.yml
├── next_sources
│   ├── css
│   ├── doc
│   ├── fonts
│   └── js
├── nornir_config.yml

От сбора данных до визуализации не за 80 дней

Основную логику будет реализовывать скрипт generate_topology.py.

Финальный штрих для инициализации Nornir

Инициализируем Nornir в Python:

from nornir import InitNornir
from nornir.plugins.tasks.networking import napalm_get

NORNIR_CONFIG_FILE = "nornir_config.yml"

nr = InitNornir(config_file=NORNIR_CONFIG_FILE)

Теперь он полностью готов к работе.
Импортированный napalm_get дает доступ к NAPALM через Nornir.


Минутка LLDP

По LLDP устройства обмениваются с прямыми соседями фреймами, содержащими набор TLV полей. LLDP-сообщения не ретранслируются.
Обязательные TLV: Chassis ID, Port ID и Time-to-Live
Опциональные: System name and description; Port name and description; VLAN name; IP management address; System capabilities (switching, routing, etc.) и прочие.
Т.к. сеть находится под нашим управлением, включим System name и Port name в набор минимально необходимых TLV.
Это не несет значительных рисков безопасности, но поможет однозначно идентифицировать мульти-шасси устройства с единым control plane (например, стеки) и связи между устройствами.

Задача построения топологии в этом случае сводится к сбору индивидуальных данных с устройств об их соседствах и определении на их основе уникальных устройств и связей между ними (т.е. вершин и ребер совокупного графа).
Схожим образом работает, например, OSPF при сборе и анализе индивидуальных LSA. И визуализация связности для протоколов маршрутизации — тоже вполне себе кейс. Но вернемся пока к LLDP.

В тестовой топологии все edge, core и distribution должны видеть своих прямых соседей. internet-rtr01 изолирован от всех и не должен иметь LLDP-соседств.
К примеру, суммарный вывод соседств с dist-rtr01:









dist-rtr01#sh lldp nei
Capability codes:
    (R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device
    (W) WLAN Access Point, (P) Repeater, (S) Station, (O) Other

Device ID           Local Intf     Hold-time  Capability      Port ID
dist-rtr02.devnet.laGi6            120        R               Gi6
dist-sw01.devnet.labGi4            120        B,R             Ethernet1/3
dist-sw02.devnet.labGi5            120        B,R             Ethernet1/3
core-rtr02.devnet.laGi3            120        R               Gi0/0/0/2
core-rtr01.devnet.laGi2            120        R               Gi0/0/0/2

Total entries displayed: 5

Пять соседей, все верно.
И с core-rtr02:

RP/0/0/CPU0:core-rtr02#sh lldp nei
Sun May 10 22:07:05.776 UTC
Capability codes:
        (R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device
        (W) WLAN Access Point, (P) Repeater, (S) Station, (O) Other

Device ID       Local Intf          Hold-time  Capability     Port ID
core-rtr01.devnet.la Gi0/0/0/0           120        R               Gi0/0/0/0
edge-sw01.devnet.lab Gi0/0/0/1           120        R               Gi0/3
dist-rtr01.devnet.la Gi0/0/0/2           120        R               Gi3
dist-rtr02.devnet.la Gi0/0/0/3           120        R               Gi3

Total entries displayed: 4

4 соседства, тоже корректно.
Обратите внимание, в обоих случаях в таблице присутствуют обрезанные хостнеймы в столбце Device ID.
Такие проблемы — извечные спутники CLI-автоматизации.
В качестве обходного пути будем ориентироваться на детальный вывод с каждого из устройств.
Для примера:



'show lldp neighbors detail' с dist-rtr01 на IOSXE
dist-rtr01#sh lldp nei det
------------------------------------------------
Local Intf: Gi6
Chassis id: 001e.e57c.cf00
Port id: Gi6
Port Description: L3 Link to dist-rtr01
System Name: dist-rtr02.devnet.lab

System Description: 
Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)
Technical Support: http://www.cisco.com/techsupport
Copyright (c) 1986-2019 by Cisco Systems, Inc.
Compiled Tue 28-May-19 12:45

Time remaining: 91 seconds
System Capabilities: B,R
Enabled Capabilities: R
Management Addresses:
    IP: 172.16.252.18
Auto Negotiation - not supported
Physical media capabilities - not advertised
Media Attachment Unit type - not advertised
Vlan ID: - not advertised

------------------------------------------------
Local Intf: Gi4
Chassis id: 5254.0007.5d59
Port id: Ethernet1/3
Port Description: L3 link to dist-rtr01
System Name: dist-sw01.devnet.lab

System Description: 
Cisco Nexus Operating System (NX-OS) Software 9.2(3)
TAC support: http://www.cisco.com/tac
Copyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved.

Time remaining: 108 seconds
System Capabilities: B,R
Enabled Capabilities: B,R
Management Addresses:
    IP: 10.10.20.177
    Other: 52 54 00 07 5D 59 00
Auto Negotiation - not supported
Physical media capabilities - not advertised
Media Attachment Unit type - not advertised
Vlan ID: - not advertised

------------------------------------------------
Local Intf: Gi5
Chassis id: 5254.0007.b7e6
Port id: Ethernet1/3
Port Description: L3 link to dist-rtr01
System Name: dist-sw02.devnet.lab

System Description: 
Cisco Nexus Operating System (NX-OS) Software 9.2(3)
TAC support: http://www.cisco.com/tac
Copyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved.

Time remaining: 97 seconds
System Capabilities: B,R
Enabled Capabilities: B,R
Management Addresses:
    IP: 10.10.20.178
    Other: 52 54 00 07 FF FF 00
Auto Negotiation - not supported
Physical media capabilities - not advertised
Media Attachment Unit type - not advertised
Vlan ID: - not advertised

------------------------------------------------
Local Intf: Gi3
Chassis id: 02c7.9dc0.0c06
Port id: Gi0/0/0/2
Port Description: L3 Link to dist-rtr01
System Name: core-rtr02.devnet.lab

System Description: 
Cisco IOS XR Software, Version 6.3.1[Default]
Copyright (c) 2017 by Cisco Systems, Inc., IOS XRv Series

Time remaining: 94 seconds
System Capabilities: R
Enabled Capabilities: R
Management Addresses:
    IP: 172.16.252.26
Auto Negotiation - not supported
Physical media capabilities - not advertised
Media Attachment Unit type - not advertised
Vlan ID: - not advertised

------------------------------------------------
Local Intf: Gi2
Chassis id: 0288.15c0.0c06
Port id: Gi0/0/0/2
Port Description: L3 Link to dist-rtr01
System Name: core-rtr01.devnet.lab

System Description: 
Cisco IOS XR Software, Version 6.3.1[Default]
Copyright (c) 2017 by Cisco Systems, Inc., IOS XRv Series

Time remaining: 110 seconds
System Capabilities: R
Enabled Capabilities: R
Management Addresses:
    IP: 172.16.252.22
Auto Negotiation - not supported
Physical media capabilities - not advertised
Media Attachment Unit type - not advertised
Vlan ID: - not advertised

Total entries displayed: 5
show lldp neighbors detail c dist-sw01 на NXOS
dist-sw01# sh lldp nei det
Capability codes:
  (R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device
  (W) WLAN Access Point, (P) Repeater, (S) Station, (O) Other
Device ID            Local Intf      Hold-time  Capability  Port ID  

Chassis id: 5254.0007.b7e4
Port id: Ethernet1/1
Local Port id: Eth1/1
Port Description: VPC Peer Link
System Name: dist-sw02.devnet.lab
System Description: Cisco Nexus Operating System (NX-OS) Software 9.2(3)
TAC support: http://www.cisco.com/tac
Copyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved.
Time remaining: 112 seconds
System Capabilities: B, R
Enabled Capabilities: B, R
Management Address: 10.10.20.178
Management Address IPV6: not advertised
Vlan ID: 1

Chassis id: 5254.0007.b7e5
Port id: Ethernet1/2
Local Port id: Eth1/2
Port Description: VPC Peer Link
System Name: dist-sw02.devnet.lab
System Description: Cisco Nexus Operating System (NX-OS) Software 9.2(3)
TAC support: http://www.cisco.com/tac
Copyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved.
Time remaining: 112 seconds
System Capabilities: B, R
Enabled Capabilities: B, R
Management Address: 10.10.20.178
Management Address IPV6: not advertised
Vlan ID: 1

Chassis id: 001e.7a2a.3900
Port id: Gi4
Local Port id: Eth1/3
Port Description: L3 Link to dist-sw01
System Name: dist-rtr01.devnet.lab
System Description: Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)
Technical Support: http://www.cisco.com/techsupport
Copyright (c) 1986-2019 by Cisco Systems, Inc.
Compiled Tue 28-May-19 12:45
Time remaining: 109 seconds
System Capabilities: B, R
Enabled Capabilities: R
Management Address: 172.16.252.2
Management Address IPV6: not advertised
Vlan ID: not advertised

Chassis id: 001e.e57c.cf00
Port id: Gi4
Local Port id: Eth1/4
Port Description: L3 Link to dist-sw01
System Name: dist-rtr02.devnet.lab
System Description: Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)
Technical Support: http://www.cisco.com/techsupport
Copyright (c) 1986-2019 by Cisco Systems, Inc.
Compiled Tue 28-May-19 12:45
Time remaining: 108 seconds
System Capabilities: B, R
Enabled Capabilities: R
Management Address: 172.16.252.6
Management Address IPV6: not advertised
Vlan ID: not advertised

Total entries displayed: 4

Получение данных с оборудования

Данные будем собирать с устройств на IOS (edge-sw01), IOSXE (internet-rtr01, distr-rtr01, distr-rtr02) и NXOS (dist-sw01, dist-sw02).
На устройствах на IOSXR (core-rtr01, core-rtr02) доступ будет закрыт.
Таким образом будут покрыты сценарии:

  1. Анализа полной связности для distribution устройств.
    Должны верно определяться уникальные ноды и линки.
  2. Обработки ошибок при отсутвии доступа для core-rtr01 и core-rtr02.
    Это не должно влиять на возможность работы с оставшимися устройтсвами.
  3. Восстановления части топологии без доступа на промежуточные устройства.
    И edge-sw01, и distr-rtr01 с distr-sw02 видят core-rtr01 и core-rtr02 с разных сторон по LLDP.
    В этом случае топология должна собраться в единое целое.

Файл хостов inventory/hosts_devnet_sb_cml.yml
---

internet-rtr01:
    hostname: 10.10.20.181
    platform: ios
    site: devnet_sandbox
    groups:
        - devnet-cml-lab

edge-sw01:
    hostname: 10.10.20.172
    platform: ios
    site: devnet_sandbox
    groups:
        - devnet-cml-lab

core-rtr01:
    # доступ на устройстве заблокирован для теста
    hostname: 10.10.20.173
    platform: iosxr
    groups:
        - devnet-cml-lab

core-rtr02:
    # доступ на устройстве заблокирован для теста
    hostname: 10.10.20.174
    platform: iosxr
    groups:
        - devnet-cml-lab

dist-rtr01:
    hostname: 10.10.20.175
    platform: ios
    groups:
        - devnet-cml-lab

dist-rtr02:
    hostname: 10.10.20.176
    platform: ios
    groups:
        - devnet-cml-lab

dist-sw01:
    hostname: 10.10.20.177
    platform: nxos_ssh
    transport: ssh
    groups:
        - devnet-cml-lab

dist-sw02:
    hostname: 10.10.20.178
    platform: nxos_ssh
    transport: ssh
    groups:
        - devnet-cml-lab

Задействуем два геттера NAPALM:

  • GET_LLDP_NEIGHBORS_DETAILS (сбор LLDP-соседств).
    Выбран детализированный вывод, т.к. в CLI-выводах суммарного могут обрезаться длинные хостнеймы.
  • GET_FACTS (общие данные об устройстве).
    Этот геттер включает данные о хостнейме и FQDN, они понадобятся.
    Помимо них, вывод может включать информацию о модели и серийном номере. Может пригодиться при визуализации.



Сбор данных обернем в функцию-Task для Nornir.
Это один из его механизмов для группировки действий на индивидуальных хостах.
Таски при массовом запуске на устройствах обрабатываются в num_workers потоков.

def get_host_data(task):
    """Nornir Task для сбора данных с целевых устройств."""
    task.run(
        task=napalm_get,
        getters=['facts', 'lldp_neighbors_detail']
    )

# Запустим таск на всех устройствах в инвентори.
# Результат сохраним в переменную для дальнейшего разбора.
get_host_data_result = nr.run(get_host_data)

Если нужно запустить таск на определенных хостах или группах, Nornir поддерживает механизм простых и комплексных фильтров над инвентори.

Разбор полученных от устройств данных

В переменной get_host_data_result хранится результат выполнения таска get_host_data на каждом из устройств.

>>> get_host_data_result
AggregatedResult (get_host_data): {'internet-rtr01': MultiResult: [Result: "get_host_data", Result: "napalm_get"], 'edge-sw01': MultiResult: [Result: "get_host_data", Result: "napalm_get"], 'core-rtr01': MultiResult: [Result: "get_host_data", Result: "napalm_get"], 'core-rtr02': MultiResult: [Result: "get_host_data", Result: "napalm_get"], 'dist-rtr01': MultiResult: [Result: "get_host_data", Result: "napalm_get"], 'dist-rtr02': MultiResult: [Result: "get_host_data", Result: "napalm_get"], 'dist-sw01': MultiResult: [Result: "get_host_data", Result: "napalm_get"], 'dist-sw02': MultiResult: [Result: "get_host_data", Result: "napalm_get"]}

Объекты результата содержат метод failed, возвращающий булевое значение, по нему можно определить, удачно ли завершился таск.
Результат можно итерировать как словарь:

>>> for device, result in get_host_data_result.items():
...     print(f'{device} failed: {result.failed}')
... 
internet-rtr01 failed: False
edge-sw01 failed: False
core-rtr01 failed: True
core-rtr02 failed: True
dist-rtr01 failed: False
dist-rtr02 failed: False
dist-sw01 failed: False
dist-sw02 failed: False

Выглядит ожидаемо.

Полная структура результата для примера:

Содержимое объекта результата с dist-rtr01
>>> get_host_data_result['dist-rtr01'][1].result
{'facts': {'uptime': 6120, 'vendor': 'Cisco', 'os_version': 'Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'serial_number': '9JDCOVUDSWN', 'model': 'CSR1000V', 'hostname': 'dist-rtr01', 'fqdn': 'dist-rtr01.devnet.lab', 'interface_list': ['GigabitEthernet1', 'GigabitEthernet2', 'GigabitEthernet3', 'GigabitEthernet4', 'GigabitEthernet5', 'GigabitEthernet6', 'Loopback0']}, 'lldp_neighbors_detail': {'GigabitEthernet6': [{'remote_chassis_id': '001e.e57c.cf00', 'remote_port': 'Gi6', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'dist-rtr02.devnet.lab', 'remote_system_description': 'Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}], 'GigabitEthernet4': [{'remote_chassis_id': '5254.0007.5d59', 'remote_port': 'Ethernet1/3', 'remote_port_description': 'L3 link to dist-rtr01', 'remote_system_name': 'dist-sw01.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}], 'GigabitEthernet5': [{'remote_chassis_id': '5254.0007.b7e6', 'remote_port': 'Ethernet1/3', 'remote_port_description': 'L3 link to dist-rtr01', 'remote_system_name': 'dist-sw02.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}], 'GigabitEthernet3': [{'remote_chassis_id': '02c7.9dc0.0c06', 'remote_port': 'Gi0/0/0/2', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'core-rtr02.devnet.lab', 'remote_system_description': 'Cisco IOS XR Software, Version 6.3.1[Default]', 'remote_system_capab': ['router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}], 'GigabitEthernet2': [{'remote_chassis_id': '0288.15c0.0c06', 'remote_port': 'Gi0/0/0/2', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'core-rtr01.devnet.lab', 'remote_system_description': 'Cisco IOS XR Software, Version 6.3.1[Default]', 'remote_system_capab': ['router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}]}}
Содержимое объекта результата с dist-sw01
>>> get_host_data_result['dist-sw01'][1].result
{'facts': {'uptime': 6090, 'vendor': 'Cisco', 'os_version': '9.2(3)', 'serial_number': '9P5OMCCMSQ4', 'model': 'Nexus9000 9000v Chassis', 'hostname': 'dist-sw01', 'fqdn': 'dist-sw01.devnet.lab', 'interface_list': ['mgmt0', 'Ethernet1/1', 'Ethernet1/2', 'Ethernet1/3', 'Ethernet1/4', 'Ethernet1/5', 'Ethernet1/6', 'Ethernet1/7', 'Ethernet1/8', 'Ethernet1/9', 'Ethernet1/10', 'Ethernet1/11', 'Ethernet1/12', 'Ethernet1/13', 'Ethernet1/14', 'Ethernet1/15', 'Ethernet1/16', 'Ethernet1/17', 'Ethernet1/18', 'Ethernet1/19', 'Ethernet1/20', 'Ethernet1/21', 'Ethernet1/22', 'Ethernet1/23', 'Ethernet1/24', 'Ethernet1/25', 'Ethernet1/26', 'Ethernet1/27', 'Ethernet1/28', 'Ethernet1/29', 'Ethernet1/30', 'Ethernet1/31', 'Ethernet1/32', 'Ethernet1/33', 'Ethernet1/34', 'Ethernet1/35', 'Ethernet1/36', 'Ethernet1/37', 'Ethernet1/38', 'Ethernet1/39', 'Ethernet1/40', 'Ethernet1/41', 'Ethernet1/42', 'Ethernet1/43', 'Ethernet1/44', 'Ethernet1/45', 'Ethernet1/46', 'Ethernet1/47', 'Ethernet1/48', 'Ethernet1/49', 'Ethernet1/50', 'Ethernet1/51', 'Ethernet1/52', 'Ethernet1/53', 'Ethernet1/54', 'Ethernet1/55', 'Ethernet1/56', 'Ethernet1/57', 'Ethernet1/58', 'Ethernet1/59', 'Ethernet1/60', 'Ethernet1/61', 'Ethernet1/62', 'Ethernet1/63', 'Ethernet1/64', 'Ethernet1/65', 'Ethernet1/66', 'Ethernet1/67', 'Ethernet1/68', 'Ethernet1/69', 'Ethernet1/70', 'Ethernet1/71', 'Ethernet1/72', 'Ethernet1/73', 'Ethernet1/74', 'Ethernet1/75', 'Ethernet1/76', 'Ethernet1/77', 'Ethernet1/78', 'Ethernet1/79', 'Ethernet1/80', 'Ethernet1/81', 'Ethernet1/82', 'Ethernet1/83', 'Ethernet1/84', 'Ethernet1/85', 'Ethernet1/86', 'Ethernet1/87', 'Ethernet1/88', 'Ethernet1/89', 'Ethernet1/90', 'Ethernet1/91', 'Ethernet1/92', 'Ethernet1/93', 'Ethernet1/94', 'Ethernet1/95', 'Ethernet1/96', 'Ethernet1/97', 'Ethernet1/98', 'Ethernet1/99', 'Ethernet1/100', 'Ethernet1/101', 'Ethernet1/102', 'Ethernet1/103', 'Ethernet1/104', 'Ethernet1/105', 'Ethernet1/106', 'Ethernet1/107', 'Ethernet1/108', 'Ethernet1/109', 'Ethernet1/110', 'Ethernet1/111', 'Ethernet1/112', 'Ethernet1/113', 'Ethernet1/114', 'Ethernet1/115', 'Ethernet1/116', 'Ethernet1/117', 'Ethernet1/118', 'Ethernet1/119', 'Ethernet1/120', 'Ethernet1/121', 'Ethernet1/122', 'Ethernet1/123', 'Ethernet1/124', 'Ethernet1/125', 'Ethernet1/126', 'Ethernet1/127', 'Ethernet1/128', 'Port-channel1', 'Loopback0', 'Vlan1', 'Vlan101', 'Vlan102', 'Vlan103', 'Vlan104', 'Vlan105']}, 'lldp_neighbors_detail': {'Ethernet1/1': [{'remote_chassis_id': '5254.0007.b7e4', 'remote_port': 'Ethernet1/1', 'remote_port_description': 'VPC Peer Link', 'remote_system_name': 'dist-sw02.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}], 'Ethernet1/2': [{'remote_chassis_id': '5254.0007.b7e5', 'remote_port': 'Ethernet1/2', 'remote_port_description': 'VPC Peer Link', 'remote_system_name': 'dist-sw02.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}], 'Ethernet1/3': [{'remote_chassis_id': '001e.7a2a.3900', 'remote_port': 'Gi4', 'remote_port_description': 'L3 Link to dist-sw01', 'remote_system_name': 'dist-rtr01.devnet.lab', 'remote_system_description': 'Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}], 'Ethernet1/4': [{'remote_chassis_id': '001e.e57c.cf00', 'remote_port': 'Gi4', 'remote_port_description': 'L3 Link to dist-sw01', 'remote_system_name': 'dist-rtr02.devnet.lab', 'remote_system_description': 'Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}]}}

Результат представляет из себя словарь с ключами 'facts' 'lldp_neighbors_detail' по названиям использованных геттеров.
Внутри все уже разложено NAPALM'ом по структурам данных.
Сверим соседства:

Соседи dist-rtr01
>>> for neighbor in get_host_data_result['dist-rtr01'][1].result['lldp_neighbors_detail'].items():
...     print(neighbor)
...     print('\n')
... 
('GigabitEthernet6', [{'remote_chassis_id': '001e.e57c.cf00', 'remote_port': 'Gi6', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'dist-rtr02.devnet.lab', 'remote_system_description': 'Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}])

('GigabitEthernet4', [{'remote_chassis_id': '5254.0007.5d59', 'remote_port': 'Ethernet1/3', 'remote_port_description': 'L3 link to dist-rtr01', 'remote_system_name': 'dist-sw01.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}])

('GigabitEthernet5', [{'remote_chassis_id': '5254.0007.b7e6', 'remote_port': 'Ethernet1/3', 'remote_port_description': 'L3 link to dist-rtr01', 'remote_system_name': 'dist-sw02.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}])

('GigabitEthernet3', [{'remote_chassis_id': '02c7.9dc0.0c06', 'remote_port': 'Gi0/0/0/2', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'core-rtr02.devnet.lab', 'remote_system_description': 'Cisco IOS XR Software, Version 6.3.1[Default]', 'remote_system_capab': ['router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}])

('GigabitEthernet2', [{'remote_chassis_id': '0288.15c0.0c06', 'remote_port': 'Gi0/0/0/2', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'core-rtr01.devnet.lab', 'remote_system_description': 'Cisco IOS XR Software, Version 6.3.1[Default]', 'remote_system_capab': ['router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}])
Соседи dist-sw01
>>> for neighbor in get_host_data_result['dist-sw01'][1].result['lldp_neighbors_detail'].items():
...     print(neighbor)
...     print('\n')
... 
('Ethernet1/1', [{'remote_chassis_id': '5254.0007.b7e4', 'remote_port': 'Ethernet1/1', 'remote_port_description': 'VPC Peer Link', 'remote_system_name': 'dist-sw02.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}])

('Ethernet1/2', [{'remote_chassis_id': '5254.0007.b7e5', 'remote_port': 'Ethernet1/2', 'remote_port_description': 'VPC Peer Link', 'remote_system_name': 'dist-sw02.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}])

('Ethernet1/3', [{'remote_chassis_id': '001e.7a2a.3900', 'remote_port': 'Gi4', 'remote_port_description': 'L3 Link to dist-sw01', 'remote_system_name': 'dist-rtr01.devnet.lab', 'remote_system_description': 'Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}])

('Ethernet1/4', [{'remote_chassis_id': '001e.e57c.cf00', 'remote_port': 'Gi4', 'remote_port_description': 'L3 Link to dist-sw01', 'remote_system_name': 'dist-rtr02.devnet.lab', 'remote_system_description': 'Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}])

5 соседей у dist-rtr01, совпадает с выводом из CLI выше.
4 соседа у dist-sw01, тоже все сходится.
Так же и на других хостах.



Для удобства дальнейшей обработки достанем из результата данные отдельно по LLDP и фактам.
Для сведения всех данных за уникальный идентификатор устройства примем в порядке убывания приоритета:

  • Его FQDN, если доступен (далее по тексту может называться хостнеймом для упрощения).
  • Его hostname, если доступен.
  • Его имя в хост-объекте inventory Nornir.
    Первыми двумя пунктами руководствуется и LLDP.
    def normalize_result(nornir_job_result):
    """
    Парсер для результата работы get_host_data.
    Возвращает словари с данными LLDP и FACTS с разбиением
    по устройствам с ключами в виде хостнеймов.
    """
    global_lldp_data = {}
    global_facts = {}
    for device, output in nornir_job_result.items():
        if output[0].failed:
            # Если таск для специфического хоста завершился ошибкой,
            # в результат для него записываются пустые списки.
            # Ключом будет являться имя его host-объекта в инвентори.
            global_lldp_data[device] = {}
            global_facts[device] = {
                'nr_ip': nr.inventory.hosts[device].get('hostname', 'n/a'),
            }
            continue
        # Для различения устройств в топологии при ее анализе
        # за идентификатор принимается FQDN устройства, как и в LLDP TLV.
        device_fqdn = output[1].result['facts']['fqdn']
        if not device_fqdn:
            # Если FQDN не задан, используется хостнейм.
            device_fqdn = output[1].result['facts']['hostname']
        if not device_fqdn:
            # Если и хостнейм не задан,
            # используется имя host-объекта в инвентори.
            device_fqdn = device
        global_facts[device_fqdn] = output[1].result['facts']
        # Допишем в facts IP-адрес оборудования
        global_facts[device_fqdn]['nr_ip'] = nr.inventory.hosts[device].get('hostname', 'n/a')
        global_lldp_data[device_fqdn] = output[1].result['lldp_neighbors_detail']
    return global_lldp_data, global_facts


Из данных по LLDP теперь необходимо извлечь список всех соседств со всех устройств и сформировать на его основе:

  • Список уникальных хостов.
  • Список уникальных линков между ними.

Для однозначной идентификации линков будем хранить их в формате:
((source_device_id, source_port_name), (destination_device_id, destination_port_name))


Стоит также учесть, что:

  • Один и тот же линк может быть виден с разных сторон с двух устройств, если на оба есть доступ.
    Нужно проверять перестановки источника и назначения при добавлении новых линков.
  • Локальное имя порта и анонсируемое по LLDP может иметь разный формат. Например, GigabitEthernet4 локально, но Gi4 в анонсе.



Для однозначной идентификации будем транслировать их в полный формат. И добавим функцию для трансляции в сокращенный вид для дальнейшего удобства визуализации.
Для автоматического выбора правильной пиктограммы устройства при визуализации будем учитывать его capabilities, анонсируемые по LLDP. Сведем их в отдельный словарь по хостнеймам.
Код:

interface_full_name_map = {
    'Eth': 'Ethernet',
    'Fa': 'FastEthernet',
    'Gi': 'GigabitEthernet',
    'Te': 'TenGigabitEthernet',
}

def if_fullname(ifname):
    for k, v in interface_full_name_map.items():
        if ifname.startswith(v):
            return ifname
        if ifname.startswith(k):
            return ifname.replace(k, v)
    return ifname

def if_shortname(ifname):
    for k, v in interface_full_name_map.items():
        if ifname.startswith(v):
            return ifname.replace(v, k)
    return ifname

def extract_lldp_details(lldp_data_dict):
    """
    Парсер данных из словаря LLDP-данных.
    Возвращает сет из всех обнаруженных в топологии хостов,
    словарь обнаруженных LLDP capabilities с ключами в виде
    хостнеймов и список уникальных связностей между хостами.
    """
    discovered_hosts = set()
    lldp_capabilities_dict = {}
    global_interconnections = []
    for host, lldp_data in lldp_data_dict.items():
        if not host:
            continue
        discovered_hosts.add(host)
        if not lldp_data:
            continue
        for interface, neighbors in lldp_data.items():
            for neighbor in neighbors:
                if not neighbor['remote_system_name']:
                    continue
                discovered_hosts.add(neighbor['remote_system_name'])
                if neighbor['remote_system_enable_capab']:
                    # В случае наличия нескольких enable capabilities
                    # в расчет берется первая по списку
                    lldp_capabilities_dict[neighbor['remote_system_name']] = (
                        neighbor['remote_system_enable_capab'][0]
                    )
                else:
                    lldp_capabilities_dict[neighbor['remote_system_name']] = ''
                # Связи между хостами первоначально сохраняются в формате:
                # ((хостнейм_источника, порт источника), (хостнейм назначения, порт_назначения))
                # и добавляются в общий список.
                local_end = (host, interface)
                remote_end = (
                    neighbor['remote_system_name'],
                    if_fullname(neighbor['remote_port'])
                )
                # При добавлении проверяется, не является ли линк перестановкой
                # источника и назначения или дублем.
                link_is_already_there = (
                    (local_end, remote_end) in global_interconnections
                    or (remote_end, local_end) in global_interconnections
                )
                if link_is_already_there:
                    continue
                global_interconnections.append((
                    (host, interface),
                    (neighbor['remote_system_name'], if_fullname(neighbor['remote_port']))
                ))
    return [discovered_hosts, global_interconnections, lldp_capabilities_dict]

Инициализация приложения NeXt UI

За всю логику отрисовки топологии будет отвечать скрипт next_app.js на основе NeXt UI.
Начнем с базовых вещей:

(function (nx) {
    /**
     * Приложение на NeXt UI
     */
    // Инициализация топологии
    var topo = new nx.graphic.Topology({
        // Ширина и высота view приложения
        width: 1200,
        height: 700,
        // Процессор данных, отвечает за расстановку нод.
        // 'force' стремится расставить ноды на равном 
        // удалении друг от друга. 'quick' расставляет их
        // в произвольных местах
        dataProcessor: 'force',
        // уникальный идентификатор нод и линков
        identityKey: 'id',
        // Конфигурация нод
        nodeConfig: {
            label: 'model.name',
            iconType:'model.icon',
        },
        // Конфигурация линков
        linkConfig: {
            // Отображение множественных линков дугами,
            // можно поменять на 'parallel'
            linkType: 'curve',
        },
        // Отображать пиктограммы нод, при false отрисует точку
        showIcon: true,
    });

    var Shell = nx.define(nx.ui.Application, {
        methods: {
            start: function () {
                // записать данные топологии из переменной
                topo.data(topologyData);
                // и прикрепить их к документу
                topo.attach(this);
            }
        }
    });

    // создать инстанс приложения
    var shell = new Shell();
    // запустить приложение
    shell.start();
})(nx);

Тополология собирается из переменной topologyData, вынесем ее в отдельный файл topology.js. Ее внутренний формат рассмотрим ниже.

Для просмотра визуализации будет использоваться локальная HTML форма, куда подключим все составные части:

<!DOCTYPE html>

<html>
    <head>
        <meta charset="utf-8">
        <link rel="stylesheet" href="next_sources/css/next.css">
        <link rel="stylesheet" href="styles_main_page.css">
        <script src="next_sources/js/next.js"></script>
        <script src="topology.js"></script>
        <script src="next_app.js"></script>
    </head>
    <body>
    </body>
</html>

Формирование топологии для NeXT UI в Python

Ранее мы уже написали необходимые обработчики результата и получили базовое представление топологии в структурах данных Python.
Применим их в действии:

GLOBAL_LLDP_DATA, GLOBAL_FACTS = normalize_result(get_host_data_result)
TOPOLOGY_DETAILS = extract_lldp_details(GLOBAL_LLDP_DATA)

Структура представления топологии для NeXt UI имеет вид:

// две ноды и два линка между ними
var topologyData = {
    "links": [
        {
            "id": 0,
            "source": 0,
            "target": 1,
        }, {
            "id": 1,
            "source": 0,
            "target": 1,
        }
    ],
    "nodes": [
        {
            "icon": "router",
            "id": 0,
        },
        {
            "icon": "router",
            "id": 1,
        }
    ]

Как видно, это JSON объект, который напрямую маппится в структуру словаря вида:
{'nodes': [], 'links': []} на Python.
Сформируем его на основе имеющихся данных.
Для выбора типа пиктограммы для нод также учтем модель устройства, если capabilities в LLDP были недоступны никому из соседей, на которых есть доступ.
В объекты нод добавим некоторые известные из FACTS данные (например, модель и серийный номер), их потом можно использовать в визуализации.



icon_capability_map = {
    'router': 'router',
    'switch': 'switch',
    'bridge': 'switch',
    'station': 'host'
}

icon_model_map = {
    'CSR1000V': 'router',
    'Nexus': 'switch',
    'IOSXRv': 'router',
    'IOSv': 'switch',
    '2901': 'router',
    '2911': 'router',
    '2921': 'router',
    '2951': 'router',
    '4321': 'router',
    '4331': 'router',
    '4351': 'router',
    '4421': 'router',
    '4431': 'router',
    '4451': 'router',
    '2960': 'switch',
    '3750': 'switch',
    '3850': 'switch',
}

def get_icon_type(device_cap_name, device_model=''):
    """
    Функция для определения типа пиктограммы устройства.
    Приоритет имеет маппинг LLDP capabilities.
    Если по ним определить тип пиктограммы не удалось,
    делается проверка по модели устройства.
    При отсутствии результата возвращается тип по умолчанию 'unknown'.
    """
    if device_cap_name:
        icon_type = icon_capability_map.get(device_cap_name)
        if icon_type:
            return icon_type
    if device_model:
        # Проверяется вхождение подстроки из ключей icon_model_map
        # В строке модели устройства до первого совпадения
        for model_shortname, icon_type in icon_model_map.items():
            if model_shortname in device_model:
                return icon_type
    return 'unknown'

def generate_topology_json(*args):
    """
    Генератор JSON-объекта топологии.
    На вход принимает сет из всех обнаруженных в топологии хостов,
    словарь обнаруженных LLDP capabilities с ключами в виде
    хостнеймов, список уникальных связностей между хостами и словарь
    с дополнительными данными об устройствах с ключами в виде хостнеймов.
    """
    discovered_hosts, interconnections, lldp_capabilities_dict, facts = args
    host_id = 0
    host_id_map = {}
    topology_dict = {'nodes': [], 'links': []}
    for host in discovered_hosts:
        device_model = 'n/a'
        device_serial = 'n/a'
        device_ip = 'n/a'
        if facts.get(host):
            device_model = facts[host].get('model', 'n/a')
            device_serial = facts[host].get('serial_number', 'n/a')
            device_ip = facts[host].get('nr_ip', 'n/a')
        host_id_map[host] = host_id
        topology_dict['nodes'].append({
            'id': host_id,
            'name': host,
            'primaryIP': device_ip,
            'model': device_model,
            'serial_number': device_serial,
            'icon': get_icon_type(
                lldp_capabilities_dict.get(host, ''),
                device_model
            )
        })
        host_id += 1
    link_id = 0
    for link in interconnections:
        topology_dict['links'].append({
            'id': link_id,
            'source': host_id_map[link[0][0]],
            'target': host_id_map[link[1][0]],
            'srcIfName': if_shortname(link[0][1]),
            'srcDevice': link[0][0],
            'tgtIfName': if_shortname(link[1][1]),
            'tgtDevice': link[1][0],
        })
        link_id += 1
    return topology_dict

Дальше дело за малым, запишем получившийся словарь в файл topology.js, воспользуемся стандартным модулем json для добавления читабельного форматирования при записи:

import json

OUTPUT_TOPOLOGY_FILENAME = 'topology.js'
TOPOLOGY_FILE_HEAD = "\n\nvar topologyData = "

def write_topology_file(topology_json, header=TOPOLOGY_FILE_HEAD, dst=OUTPUT_TOPOLOGY_FILENAME):
    with open(dst, 'w') as topology_file:
        topology_file.write(header)
        topology_file.write(json.dumps(topology_json, indent=4, sort_keys=True))
        topology_file.write(';')

TOPOLOGY_DICT = generate_topology_json(*TOPOLOGY_DETAILS)
write_topology_file(TOPOLOGY_DICT)
Получившийся в результате topology.js

var topologyData = {
    "links": [
        {
            "id": 0,
            "source": 7,
            "srcDevice": "edge-sw01.devnet.lab",
            "srcIfName": "Gi0/2",
            "target": 5,
            "tgtDevice": "core-rtr01.devnet.lab",
            "tgtIfName": "Gi0/0/0/1"
        },
        {
            "id": 1,
            "source": 7,
            "srcDevice": "edge-sw01.devnet.lab",
            "srcIfName": "Gi0/3",
            "target": 3,
            "tgtDevice": "core-rtr02.devnet.lab",
            "tgtIfName": "Gi0/0/0/1"
        },
        {
            "id": 2,
            "source": 4,
            "srcDevice": "dist-rtr01.devnet.lab",
            "srcIfName": "Gi3",
            "target": 3,
            "tgtDevice": "core-rtr02.devnet.lab",
            "tgtIfName": "Gi0/0/0/2"
        },
        {
            "id": 3,
            "source": 4,
            "srcDevice": "dist-rtr01.devnet.lab",
            "srcIfName": "Gi4",
            "target": 1,
            "tgtDevice": "dist-sw01.devnet.lab",
            "tgtIfName": "Eth1/3"
        },
        {
            "id": 4,
            "source": 4,
            "srcDevice": "dist-rtr01.devnet.lab",
            "srcIfName": "Gi6",
            "target": 0,
            "tgtDevice": "dist-rtr02.devnet.lab",
            "tgtIfName": "Gi6"
        },
        {
            "id": 5,
            "source": 4,
            "srcDevice": "dist-rtr01.devnet.lab",
            "srcIfName": "Gi5",
            "target": 2,
            "tgtDevice": "dist-sw02.devnet.lab",
            "tgtIfName": "Eth1/3"
        },
        {
            "id": 6,
            "source": 4,
            "srcDevice": "dist-rtr01.devnet.lab",
            "srcIfName": "Gi2",
            "target": 5,
            "tgtDevice": "core-rtr01.devnet.lab",
            "tgtIfName": "Gi0/0/0/2"
        },
        {
            "id": 7,
            "source": 0,
            "srcDevice": "dist-rtr02.devnet.lab",
            "srcIfName": "Gi3",
            "target": 3,
            "tgtDevice": "core-rtr02.devnet.lab",
            "tgtIfName": "Gi0/0/0/3"
        },
        {
            "id": 8,
            "source": 0,
            "srcDevice": "dist-rtr02.devnet.lab",
            "srcIfName": "Gi4",
            "target": 1,
            "tgtDevice": "dist-sw01.devnet.lab",
            "tgtIfName": "Eth1/4"
        },
        {
            "id": 9,
            "source": 0,
            "srcDevice": "dist-rtr02.devnet.lab",
            "srcIfName": "Gi5",
            "target": 2,
            "tgtDevice": "dist-sw02.devnet.lab",
            "tgtIfName": "Eth1/4"
        },
        {
            "id": 10,
            "source": 0,
            "srcDevice": "dist-rtr02.devnet.lab",
            "srcIfName": "Gi2",
            "target": 5,
            "tgtDevice": "core-rtr01.devnet.lab",
            "tgtIfName": "Gi0/0/0/3"
        },
        {
            "id": 11,
            "source": 1,
            "srcDevice": "dist-sw01.devnet.lab",
            "srcIfName": "Eth1/1",
            "target": 2,
            "tgtDevice": "dist-sw02.devnet.lab",
            "tgtIfName": "Eth1/1"
        },
        {
            "id": 12,
            "source": 1,
            "srcDevice": "dist-sw01.devnet.lab",
            "srcIfName": "Eth1/2",
            "target": 2,
            "tgtDevice": "dist-sw02.devnet.lab",
            "tgtIfName": "Eth1/2"
        }
    ],
    "nodes": [
        {
            "icon": "router",
            "id": 0,
            "model": "CSR1000V",
            "name": "dist-rtr02.devnet.lab",
            "serial_number": "9YZKNQKQ566",
            "layerSortPreference": 7,
            "primaryIP": "10.10.20.176",
            "dcimDeviceLink": "http://localhost:32768/dcim/devices/?q=dist-rtr02.devnet.lab"
        },
        {
            "icon": "switch",
            "id": 1,
            "model": "Nexus9000 9000v Chassis",
            "name": "dist-sw01.devnet.lab",
            "serial_number": "9MZLNM0ZC9Z",
        },
        {
            "icon": "switch",
            "id": 2,
            "model": "Nexus9000 9000v Chassis",
            "name": "dist-sw02.devnet.lab",
            "serial_number": "93LCGCRUJA5",
        },
        {
            "icon": "router",
            "id": 3,
            "model": "n/a",
            "name": "core-rtr02.devnet.lab",
            "serial_number": "n/a",
        },
        {
            "icon": "router",
            "id": 4,
            "model": "CSR1000V",
            "name": "dist-rtr01.devnet.lab",
            "serial_number": "9S78ZRF2V2B",
        },
        {
            "icon": "router",
            "id": 5,
            "model": "n/a",
            "name": "core-rtr01.devnet.lab",
            "serial_number": "n/a",
        },
        {
            "icon": "router",
            "id": 6,
            "model": "CSR1000V",
            "name": "internet-rtr01.virl.info",
            "serial_number": "9LGWPM8GTV6",
        },
        {
            "icon": "switch",
            "id": 7,
            "model": "IOSv",
            "name": "edge-sw01.devnet.lab",
            "serial_number": "927A4RELIGI",
        }
    ]
};

Запустим main.html и увидим наш визуализационный Hello World:

Похоже на правду. Все известные ноды и линки отображены.
Ноды можно выделять и перетаскивать мышью в произвольном направлении, при клике на ноды и линки появляется встроенная в NeXt UI форма с атрибутами, кототорые мы передали в объекты нод в топологию:

Уже неплохо, но можно лучше. Вернемся к отображению позже, а пока займемся второй частью задачи.

Поиск и визуализация изменений в топологии

По условию была дополнительная задача на визуализацию изменений в топологии.
Решим и ее. Сперва, введем некоторые дополнения:

  • Файл cached_topology.json для хранения прошлой версии топологии.
    Он будет считываться при каждом старте generate_topology.py для сравнения и перезаписываться новой версией топологии.
  • Файл diff_topology.js для хранения топологии с изменениями.
  • Файл diff_page.html для отображения визуализации изменений.

HTML-форма для визуализации:

<!DOCTYPE html>

<html>
    <head>
        <meta charset="utf-8">
        <link rel="stylesheet" href="next_sources/css/next.css">
        <link rel="stylesheet" href="styles_main_page.css">
        <script src="next_sources/js/next.js"></script>
        <script src="diff_topology.js"></script>
        <script src="next_app.js"></script>
    </head>
    <body>
        <a href="main.html"><button>Показать текущую топологию</button></a>
        </p>
    </body>
</html>

Все необходимое для чтения и записи кэша топологии:

CACHED_TOPOLOGY_FILENAME = 'cached_topology.json'

def write_topology_cache(topology_json, dst=CACHED_TOPOLOGY_FILENAME):
    with open(dst, 'w') as cached_file:
        cached_file.write(json.dumps(topology_json, indent=4, sort_keys=True))

def read_cached_topology(filename=CACHED_TOPOLOGY_FILENAME):
    if not os.path.exists(filename):
        return {}
    if not os.path.isfile(filename):
        return {}
    cached_topology = {}
    with open(filename, 'r') as file:
        try:
            cached_topology = json.loads(file.read())
        except:
            return {}
    return cached_topology

Для поиска и визуализации изменений в топологии:

  1. Из словарей текущей и кэшированной топологии извлечем необходимые для сравнения атрибуты нод и линков.
    Формат для нод:
    (исходные данные ноды с полным набором атрибутов, (хостнейм,))
    Формат для линков:
    (исходные данные линка с полным набором атрибутов, (хостнейм_источника, порт источника), (хостнейм назначения, порт_назначения))
    Формат выбран для возможности добавления атрибутов для сравнения и более гибкого поиска изменений.




  2. По этим объектам выполним поиск изменений по нодам и линкам (с учетом перестановок источник-назначение).
    Изменения по нодам будут записаны в два словаря формата:
    • diff_nodes = {'added': [], 'deleted': []}
    • diff_links = {'added': [], 'deleted': []}


  3. В ходе поиска изменений выполним слияние текущей и кэшированной топологии.
    Результирующая топология будет содержаться в словаре diff_merged_topology.
    Для визуализации изменений при обнаружении удаленных и добавленных нод и линков исходные данные будем расширять дополнительными атрибутами с говорящим названием is_new и is_dead.
    Для удаленных нод для наглядности также введем отдельный тип пиктограммы 'dead_node' (в NeXt UI учтем это ниже).




Реализуем обозначенную логику в коде:

def get_topology_diff(cached, current):
    """
    Функция поиска изменений в топологии.
    На вход принимает два словаря с кэшированной и текущей
    топологиями. На выходе возвращает список словарей с изменениями
    по хостам и линкам, а также словарь с результатом слияния
    сравниваемых топологий с расширенными атрибутами
    для визуализации изменений.
    """
    diff_nodes = {'added': [], 'deleted': []}
    diff_links = {'added': [], 'deleted': []}
    diff_merged_topology = {'nodes': [], 'links': []}
    # Линки парсятся из объектов топологии в формат:
    # (исходник, (хостнейм_источника, порт источника), (хостнейм назначения, порт_назначения))
    cached_links = [(x, ((x['srcDevice'], x['srcIfName']), (x['tgtDevice'], x['tgtIfName']))) for x in cached['links']]
    links = [(x, ((x['srcDevice'], x['srcIfName']), (x['tgtDevice'], x['tgtIfName']))) for x in current['links']]
    # Хосты парсятся из объектов топологии в формат:
    # (исходные данные, (хостнейм,))
    # В кортеж при дальнейшей разработке могут добавляться дополнительные параметры для сравнения.
    cached_nodes = [(x, (x['name'],)) for x in cached['nodes']]
    nodes = [(x, (x['name'],)) for x in current['nodes']]
    # Выполняется поиск добавленных и удаленных хостнеймов в топологии.
    node_id = 0
    host_id_map = {}
    for raw_data, node in nodes:
        if node in [x[1] for x in cached_nodes]:
            raw_data['id'] = node_id
            host_id_map[raw_data['name']] = node_id
            raw_data['is_new'] = 'no'
            raw_data['is_dead'] = 'no'
            diff_merged_topology['nodes'].append(raw_data)
            node_id += 1
            continue
        diff_nodes['added'].append(node)
        raw_data['id'] = node_id
        host_id_map[raw_data['name']] = node_id
        raw_data['is_new'] = 'yes'
        raw_data['is_dead'] = 'no'
        diff_merged_topology['nodes'].append(raw_data)
        node_id += 1
    for raw_data, cached_node in cached_nodes:
        if cached_node in [x[1] for x in nodes]:
            continue
        diff_nodes['deleted'].append(cached_node)
        raw_data['id'] = node_id
        host_id_map[raw_data['name']] = node_id
        raw_data['is_new'] = 'no'
        raw_data['is_dead'] = 'yes'
        raw_data['icon'] = 'dead_node'
        diff_merged_topology['nodes'].append(raw_data)
        node_id += 1
    # Выполняется поиск новых и удаленных связей между устройствами.
    # Смена интерфейса между парой устройств рассматривается
    # как добавление одной связи и добавление другой.
    # При проверке учитывается формат хранения и
    # выполняется проверка на перестановки источника и назначения:
    # ((h1, Gi1), (h2, Gi2)) и ((h2, Gi2), (h1, Gi1)) - одно и тоже.
    link_id = 0
    for raw_data, link in links:
        src, dst = link
        if not (src, dst) in [x[1] for x in cached_links] and not (dst, src) in [x[1] for x in cached_links]:
            diff_links['added'].append((src, dst))
            raw_data['id'] = link_id
            link_id += 1
            raw_data['source'] = host_id_map[src[0]]
            raw_data['target'] = host_id_map[dst[0]]
            raw_data['is_new'] = 'yes'
            raw_data['is_dead'] = 'no'
            diff_merged_topology['links'].append(raw_data)
            continue
        raw_data['id'] = link_id
        link_id += 1
        raw_data['source'] = host_id_map[src[0]]
        raw_data['target'] = host_id_map[dst[0]]
        raw_data['is_new'] = 'no'
        raw_data['is_dead'] = 'no'
        diff_merged_topology['links'].append(raw_data)
    for raw_data, link in cached_links:
        src, dst = link
        if not (src, dst) in [x[1] for x in links] and not (dst, src) in [x[1] for x in links]:
            diff_links['deleted'].append((src, dst))
            raw_data['id'] = link_id
            link_id += 1
            raw_data['source'] = host_id_map[src[0]]
            raw_data['target'] = host_id_map[dst[0]]
            raw_data['is_new'] = 'no'
            raw_data['is_dead'] = 'yes'
            diff_merged_topology['links'].append(raw_data)
    return diff_nodes, diff_links, diff_merged_topology

get_topology_diff фактически реализует сравнение двух произвольных словарей топологии валидного формата.
С учетом этого довольно легко можно при необходимости реализовать версионирование кэша топологий.
Напишем дополнительно функцию для форматированного вывода результата в консоль:

def print_diff(diff_result):
    """
    Функция для форматированного вывода
    результата get_topology_diff в консоль.
    """
    diff_nodes, diff_links, *ignore = diff_result
    if not (diff_nodes['added'] or diff_nodes['deleted'] or diff_links['added'] or diff_links['deleted']):
        print('Изменений в топологии не обнаружено.')
        return
    print('Обнаружены изменения в топологии:')
    if diff_nodes['added']:
        print('')
        print('^^^^^^^^^^^^^^^^^^^^^^^^^^^^^')
        print('Новые сетевые устройства:')
        print('vvvvvvvvvvvvvvvvvvvvvvvvvvvvv')
        for node in diff_nodes['added']:
            print(f'Имя устройства: {node[0]}')
    if diff_nodes['deleted']:
        print('')
        print('^^^^^^^^^^^^^^^^^^^^^^^^^^^^^')
        print('Удаленные сетевые устройства:')
        print('vvvvvvvvvvvvvvvvvvvvvvvvvvvvv')
        for node in diff_nodes['deleted']:
            print(f'Имя устройства: {node[0]}')
    if diff_links['added']:
        print('')
        print('^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^')
        print('Новые соединения между устройствами:')
        print('vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv')
        for src, dst in diff_links['added']:
            print(f'От {src[0]}({src[1]}) к {dst[0]}({dst[1]})')
    if diff_links['deleted']:
        print('')
        print('^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^')
        print('Удаленные соединения между устройствами:')
        print('vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv')
        for src, dst in diff_links['deleted']:
            print(f'От {src[0]}({src[1]}) к {dst[0]}({dst[1]})')
    print('')

Сведем воедино всю ранее написанную логику в выделенную main() функцию и получим довольно самодокументированный код:

def good_luck_have_fun():
    """Функция, реализующая итоговую логику."""
    get_host_data_result = nr.run(get_host_data)
    GLOBAL_LLDP_DATA, GLOBAL_FACTS = normalize_result(get_host_data_result)
    TOPOLOGY_DETAILS = extract_lldp_details(GLOBAL_LLDP_DATA)
    TOPOLOGY_DETAILS.append(GLOBAL_FACTS)
    TOPOLOGY_DICT = generate_topology_json(*TOPOLOGY_DETAILS)
    CACHED_TOPOLOGY = read_cached_topology()
    write_topology_file(TOPOLOGY_DICT)
    write_topology_cache(TOPOLOGY_DICT)
    print(f'Для просмотра топологии откройте файл main.html')
    if CACHED_TOPOLOGY:
        DIFF_DATA = get_topology_diff(CACHED_TOPOLOGY, TOPOLOGY_DICT)
        print_diff(DIFF_DATA)
        write_topology_file(DIFF_DATA[2], dst='diff_topology.js')
    else:
        # если кэша топологии нет, файл будет содержать текущую топологию
        write_topology_file(TOPOLOGY_DICT, dst='diff_topology.js')

if __name__ == '__main__':
    good_luck_have_fun()

Тестирование

Для теста ограничим доступ на dist-rtr01 и получим следующую исходную топологию:

После чего вернем доступ на dist-rtr02, но закроем на edge-sw01.

Предыдущая версия окажется закэшированной, а текущей будет такая:

Результирующая diff_topology.js на основе их сравнения.
var topologyData = {
    "links": [
        {
            "id": 0,
            "is_dead": "no",
            "is_new": "yes",
            "source": 4,
            "srcDevice": "dist-rtr01.devnet.lab",
            "srcIfName": "Gi3",
            "target": 3,
            "tgtDevice": "core-rtr02.devnet.lab",
            "tgtIfName": "Gi0/0/0/2"
        },
        {
            "id": 1,
            "is_dead": "no",
            "is_new": "yes",
            "source": 4,
            "srcDevice": "dist-rtr01.devnet.lab",
            "srcIfName": "Gi4",
            "target": 1,
            "tgtDevice": "dist-sw01.devnet.lab",
            "tgtIfName": "Eth1/3"
        },
        {
            "id": 2,
            "is_dead": "no",
            "is_new": "yes",
            "source": 4,
            "srcDevice": "dist-rtr01.devnet.lab",
            "srcIfName": "Gi6",
            "target": 0,
            "tgtDevice": "dist-rtr02.devnet.lab",
            "tgtIfName": "Gi6"
        },
        {
            "id": 3,
            "is_dead": "no",
            "is_new": "yes",
            "source": 4,
            "srcDevice": "dist-rtr01.devnet.lab",
            "srcIfName": "Gi5",
            "target": 2,
            "tgtDevice": "dist-sw02.devnet.lab",
            "tgtIfName": "Eth1/3"
        },
        {
            "id": 4,
            "is_dead": "no",
            "is_new": "yes",
            "source": 4,
            "srcDevice": "dist-rtr01.devnet.lab",
            "srcIfName": "Gi2",
            "target": 5,
            "tgtDevice": "core-rtr01.devnet.lab",
            "tgtIfName": "Gi0/0/0/2"
        },
        {
            "id": 5,
            "is_dead": "no",
            "is_new": "no",
            "source": 0,
            "srcDevice": "dist-rtr02.devnet.lab",
            "srcIfName": "Gi3",
            "target": 3,
            "tgtDevice": "core-rtr02.devnet.lab",
            "tgtIfName": "Gi0/0/0/3"
        },
        {
            "id": 6,
            "is_dead": "no",
            "is_new": "no",
            "source": 0,
            "srcDevice": "dist-rtr02.devnet.lab",
            "srcIfName": "Gi4",
            "target": 1,
            "tgtDevice": "dist-sw01.devnet.lab",
            "tgtIfName": "Eth1/4"
        },
        {
            "id": 7,
            "is_dead": "no",
            "is_new": "no",
            "source": 0,
            "srcDevice": "dist-rtr02.devnet.lab",
            "srcIfName": "Gi5",
            "target": 2,
            "tgtDevice": "dist-sw02.devnet.lab",
            "tgtIfName": "Eth1/4"
        },
        {
            "id": 8,
            "is_dead": "no",
            "is_new": "no",
            "source": 0,
            "srcDevice": "dist-rtr02.devnet.lab",
            "srcIfName": "Gi2",
            "target": 5,
            "tgtDevice": "core-rtr01.devnet.lab",
            "tgtIfName": "Gi0/0/0/3"
        },
        {
            "id": 9,
            "is_dead": "no",
            "is_new": "no",
            "source": 1,
            "srcDevice": "dist-sw01.devnet.lab",
            "srcIfName": "Eth1/1",
            "target": 2,
            "tgtDevice": "dist-sw02.devnet.lab",
            "tgtIfName": "Eth1/1"
        },
        {
            "id": 10,
            "is_dead": "no",
            "is_new": "no",
            "source": 1,
            "srcDevice": "dist-sw01.devnet.lab",
            "srcIfName": "Eth1/2",
            "target": 2,
            "tgtDevice": "dist-sw02.devnet.lab",
            "tgtIfName": "Eth1/2"
        },
        {
            "id": 11,
            "is_dead": "yes",
            "is_new": "no",
            "source": 7,
            "srcDevice": "edge-sw01.devnet.lab",
            "srcIfName": "Gi0/2",
            "target": 5,
            "tgtDevice": "core-rtr01.devnet.lab",
            "tgtIfName": "Gi0/0/0/1"
        },
        {
            "id": 12,
            "is_dead": "yes",
            "is_new": "no",
            "source": 7,
            "srcDevice": "edge-sw01.devnet.lab",
            "srcIfName": "Gi0/3",
            "target": 3,
            "tgtDevice": "core-rtr02.devnet.lab",
            "tgtIfName": "Gi0/0/0/1"
        }
    ],
    "nodes": [
        {
            "icon": "router",
            "id": 0,
            "is_dead": "no",
            "is_new": "no",
            "model": "CSR1000V",
            "name": "dist-rtr02.devnet.lab",
            "serial_number": "9YZKNQKQ566",
        },
        {
            "icon": "switch",
            "id": 1,
            "is_dead": "no",
            "is_new": "no",
            "model": "Nexus9000 9000v Chassis",
            "name": "dist-sw01.devnet.lab",
            "serial_number": "9MZLNM0ZC9Z",
        },
        {
            "icon": "switch",
            "id": 2,
            "is_dead": "no",
            "is_new": "no",
            "model": "Nexus9000 9000v Chassis",
            "name": "dist-sw02.devnet.lab",
            "serial_number": "93LCGCRUJA5",
        },
        {
            "icon": "router",
            "id": 3,
            "is_dead": "no",
            "is_new": "no",
            "model": "n/a",
            "name": "core-rtr02.devnet.lab",
            "serial_number": "n/a",
        },
        {
            "icon": "router",
            "id": 4,
            "is_dead": "no",
            "is_new": "yes",
            "model": "CSR1000V",
            "name": "dist-rtr01.devnet.lab",
            "serial_number": "9S78ZRF2V2B",
        },
        {
            "icon": "router",
            "id": 5,
            "is_dead": "no",
            "is_new": "no",
            "model": "n/a",
            "name": "core-rtr01.devnet.lab",
            "serial_number": "n/a",
        },
        {
            "icon": "unknown",
            "id": 6,
            "is_dead": "no",
            "is_new": "no",
            "model": "CSR1000V",
            "name": "internet-rtr01.virl.info",
            "serial_number": "9LGWPM8GTV6",
        },
        {
            "icon": "dead_node",
            "id": 7,
            "is_dead": "yes",
            "is_new": "no",
            "model": "IOSv",
            "name": "edge-sw01.devnet.lab",
            "serial_number": "927A4RELIGI",
        }
    ]
};

Для ее визуализации ниже внесем некоторые изменения в приложение на NeXt UI в next_app.js.
А пока консольный вывод:

$ python3.7 generate_topology.py 
Для просмотра топологии откройте файл main.html

Обнаружены изменения в топологии:

^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Новые сетевые устройства:
vvvvvvvvvvvvvvvvvvvvvvvvvvvvv
Имя устройства: dist-rtr01.devnet.lab

^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Удаленные сетевые устройства:
vvvvvvvvvvvvvvvvvvvvvvvvvvvvv
Имя устройства: edge-sw01.devnet.lab

^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Новые соединения между устройствами:
vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
От dist-rtr01.devnet.lab(Gi3) к core-rtr02.devnet.lab(Gi0/0/0/2)
От dist-rtr01.devnet.lab(Gi4) к dist-sw01.devnet.lab(Eth1/3)
От dist-rtr01.devnet.lab(Gi6) к dist-rtr02.devnet.lab(Gi6)
От dist-rtr01.devnet.lab(Gi5) к dist-sw02.devnet.lab(Eth1/3)
От dist-rtr01.devnet.lab(Gi2) к core-rtr01.devnet.lab(Gi0/0/0/2)

^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Удаленные соединения между устройствами:
vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
От edge-sw01.devnet.lab(Gi0/2) к core-rtr01.devnet.lab(Gi0/0/0/1)
От edge-sw01.devnet.lab(Gi0/3) к core-rtr02.devnet.lab(Gi0/0/0/1)

Для просмотра топологии с визуализацией изменений откройте файл diff_page.html
Либо откройте файл main.html и нажмите кнопку 'Показать визуализацию изменений

Все согласно произведенным изменениям.

Доработка приложения на NeXt UI

Основная часть доработок творчески адаптирована из примеров в документации и туториалов по NeXt UI.

Отображение линков

Для добавления подписей к линкам расширим стандартный класс nx.graphic.Topology.Link:

    nx.define('CustomLinkClass', nx.graphic.Topology.Link, {
        properties: {
            sourcelabel: null,
            targetlabel: null
        },
        view: function(view) {
            view.content.push({
                name: 'source',
                type: 'nx.graphic.Text',
                props: {
                    'class': 'sourcelabel',
                    'alignment-baseline': 'text-after-edge',
                    'text-anchor': 'start'
                }
            }, {
                name: 'target',
                type: 'nx.graphic.Text',
                props: {
                    'class': 'targetlabel',
                    'alignment-baseline': 'text-after-edge',
                    'text-anchor': 'end'
                }
            });
            return view;
        },
        methods: {
            update: function() {
                this.inherited();
                var el, point;
                var line = this.line();
                var angle = line.angle();
                var stageScale = this.stageScale();
                line = line.pad(18 * stageScale, 18 * stageScale);
                if (this.sourcelabel()) {
                    el = this.view('source');
                    point = line.start;
                    el.set('x', point.x);
                    el.set('y', point.y);
                    el.set('text', this.sourcelabel());
                    el.set('transform', 'rotate(' + angle + ' ' + point.x + ',' + point.y + ')');
                    el.setStyle('font-size', 12 * stageScale);
                }
                if (this.targetlabel()) {
                    el = this.view('target');
                    point = line.end;
                    el.set('x', point.x);
                    el.set('y', point.y);
                    el.set('text', this.targetlabel());
                    el.set('transform', 'rotate(' + angle + ' ' + point.x + ',' + point.y + ')');
                    el.setStyle('font-size', 12 * stageScale);
                }
            }
        }
    });

И укажем его кастомную версию в свойствах объекта топологии topo.
Помимо этого, новые линки покрасим в зеленый цвет, а удаленные сделаем красными пунктирными.

linkConfig: {
    // Отображение множественных линков дугами,
    // можно поменять на 'parallel'
    linkType: 'curve',
    sourcelabel: 'model.srcIfName',
    targetlabel: 'model.tgtIfName',
    style: function(model) {
        if (model._data.is_dead === 'yes') {
            return { 'stroke-dasharray': '5' }
        }
    },
    color: function(model) {
        if (model._data.is_dead === 'yes') {
            return '#E40039'
        }
        if (model._data.is_new === 'yes') {
            return '#148D09'
        }
    },
},

Добавление кастомных пиктограмм

Для сетевого оборудования NeXt уже включает в себя набор стандартных пиктограмм.
Но имеется возможность добавления кастомных. Добавим такую для удаленных нод.
Для этого во время инициализации объекта топологии или после добавить:

// пиктограмма предварительно сохранена в ./img/dead_node.png
topo.registerIcon("dead_node", "img/dead_node.png", 49, 49);

Минутка визуализации изменений

Теперь с учетом проделанных изменений можем открыть diff_page.html и посмотреть на визуализацию сгенерированных выше изменений:

Наглядно. Как считаете?

Изменение отображения выпадающих меню

Меню по умолчанию выдает много лишней служебной информации.
Оно также может быть кастомизировано в NeXt UI.
Заложим в кастомную версию:

  • Отображение хостнейма устройства.
  • Переход на заданный линк (например, страницу устройства в NetBox) по нажатию на хостнейм в меню.
    Ссылка будет считываться из атрибута ноды dcimDeviceLink.
    Его можно добавить при генерации файла топологии. При отсутствии будет отображаться просто хостнейм.

  • Отображение IP-адреса, серийного номера и модели устройства.

Для этого расширим стандартный класс nx.ui.Component и сверстаем внутри новую форму:

    nx.define('CustomNodeTooltip', nx.ui.Component, {
        properties: {
            node: {},
            topology: {}
        },
        view: {
            content: [{
                tag: 'div',
                content: [{
                    tag: 'h5',
                    content: [{
                        tag: 'a',
                        content: '{#node.model.name}',
                        props: {"href": "{#node.model.dcimDeviceLink}"}
                    }],
                    props: {
                        "style": "border-bottom: dotted 1px; font-size:90%; word-wrap:normal; color:#003688"
                    }
                }, {
                    tag: 'p',
                    content: [
                        {
                        tag: 'label',
                        content: 'IP: ',
                    }, {
                        tag: 'label',
                        content: '{#node.model.primaryIP}',
                    }
                    ],
                    props: {
                        "style": "font-size:80%;"
                    }
                },{
                    tag: 'p',
                    content: [
                        {
                        tag: 'label',
                        content: 'Model: ',
                    }, {
                        tag: 'label',
                        content: '{#node.model.model}',
                    }
                    ],
                    props: {
                        "style": "font-size:80%;"
                    }
                }, {
                    tag: 'p',
                    content: [{
                        tag: 'label',
                        content: 'S/N: ',
                    }, {
                        tag: 'label',
                        content: '{#node.model.serial_number}',
                    }],
                    props: {
                        "style": "font-size:80%; padding:0"
                    }
                },
            ],
            props: {
                "style": "width: 150px;"
            }
        }]
        }
    });

    nx.define('Tooltip.Node', nx.ui.Component, {
        view: function(view){
            view.content.push({
            });
            return view;
        },
        methods: {
            attach: function(args) {
                this.inherited(args);
                this.model();
            }
        }
    });

Укажем кастомный класс в настройках объекта топологии topo:

tooltipManagerConfig: {
    // Настройки tooltip content (меню при нажатии на ноду)
    nodeTooltipContentClass: 'CustomNodeTooltip'
},

В результате при нажатии на ноду получим:

Изменение ориентации нод в пространстве

Как уже было обозначено, для отрисовки топологий используется 'force' процессор данных из NeXt UI. Его внутренний алгоритм стремится расположить ноды таким образом, чтобы расстояние между соседями было примерно одинаковым.

В силу этой логики для комплексных иерархических топологий расположение уровней и результата в целом может быть повернутым в горизонтальной плоскости относительно желаемого. Можно, конечно, перетащить ноды мышью, но это не наш путь.

В NeXt UI имеются встроенные средства работы со слоями.

На стороне приложения для сортировки слоев введем числовой атрибут нод layerSortPreference.

Всю логику определения иерархии в этом случае можно вынести за пределы инструмента визуализации, а ему оставить только отрисовку слоев в правильном порядке, что более масштабируемо.

Функции для изменения ориентации уровней в топологии:

    var currentLayout = 'auto'
    horizontal = function() {
        if (currentLayout === 'horizontal') {
            return;
        };
        currentLayout = 'horizontal';
        var layout = topo.getLayout('hierarchicalLayout');
        layout.direction('horizontal');
        layout.levelBy(function(node, model) {
            return model.get('layerSortPreference');
        });
        topo.activateLayout('hierarchicalLayout');
    };
    vertical = function() {
        if (currentLayout === 'vertical') {
            return;
        };
        currentLayout = 'vertical';
        var layout = topo.getLayout('hierarchicalLayout');
        layout.direction('vertical');
        layout.levelBy(function(node, model) {
          return model.get('layerSortPreference');
        });
        topo.activateLayout('hierarchicalLayout');
    };

Их вынесем на кнопки в наши формы main.html и diff_page.html:

<button onclick='horizontal()'>Ориентировать уровни горизонтально</button>
<button onclick="vertical()">Ориентировать уровни вертикально</button>

В скрипте generate_topology.py введем иерархию уровней со стандартными названиями и напишем логику определения номера уровня:

NX_LAYER_SORT_ORDER = (
    'undefined',
    'outside',
    'edge-switch',
    'edge-router',
    'core-router',
    'core-switch',
    'distribution-router',
    'distribution-switch',
    'leaf',
    'spine',
    'access-switch'
)

def get_node_layer_sort_preference(device_role):
    for i, role in enumerate(NX_LAYER_SORT_ORDER, start=1):
        if device_role == role:
            return i
    return 1

В данном случае он будет совпадать с порядковым номером элемента в NX_LAYER_SORT_ORDER сверху вниз.
Важное замечание: 0(ноль) NeXt UI, похоже, воспринимает как undefined и отправляет это уровень в конец, а не в начало. Поэтому очередность начинается с единицы.


Для определения роли(уровня) конкретного устройства введем в файле хостов инвентори Nornir соответствующее поле.
Нестандартные поля можно указать в data хоста:

dist-rtr01:
    hostname: 10.10.20.175
    platform: ios
    groups:
        - devnet-cml-lab
    data:
        role: distribution-router

Введем дополнительный атрибут nr_role, который будем записывать в словарь global_facts в normalize_result:

# полный вывод функции опустим
global_facts[device_fqdn]['nr_role'] = nr.inventory.hosts[device].get('role', 'undefined')

И считывать в generate_topology_json при формировании объекта ноды:

# полный вывод функции опустим
device_role = facts[host].get('nr_role', 'undefined')
topology_dict['nodes'].append({
    'id': host_id,
    'name': host,
    'primaryIP': device_ip,
    'model': device_model,
    'serial_number': device_serial,
    'layerSortPreference': get_node_layer_sort_preference(
        device_role
    ),
    'icon': get_icon_type(
        lldp_capabilities_dict.get(host, ''),
        device_model
    )
})

В результате получим возможность автоматически выравнивать произвольно расположенные в пространстве уровни по горизонтали или вертикали. Выглядит это при наличии необходимых атрибутов в нодах так:

Итоговая структура проекта

Полные исходники и файлы-примеры топологий можно найти на моей странице на GitHub.
Итоговый проект выглядит следующим образом:

$ tree . -L 2
.
├── LICENSE
├── README.md
├── diff_page.html
├── diff_topology.js
├── generate_topology.py
├── img
│   └── dead_node.png
├── inventory
│   ├── groups.yml
│   └── hosts_devnet_sb_cml.yml
├── main.html
├── next_app.js
├── next_sources
│   ├── css
│   ├── doc
│   ├── fonts
│   └── js
├── nornir_config.yml
├── requirements.txt
├── samples
│   ├── sample_diff.png
│   ├── sample_layout_horizontal.png
│   ├── sample_link_details.png
│   ├── sample_node_details.png
│   └── sample_topology.png
├── styles_main_page.css
└── topology.js

Выводы

В первую очередь, спасибо всем, кто дочитал до конца.

В статье постарался изложить и прокомментировать основные этапы разработки итогового варианта, а также сделать обзор используемых инструментов. Решение на марафоне заняло первое место и, что главное, является кросплатформенным и имеет потенциал к дальнейшему расширению и интеграции не только в лабораторных условиях.

Надеюсь, это может быть кому-то полезно.

Буду рад обратной связи и конструктивной критике. Что можно было бы изменить или улучшить?

Как бы вы подошли к решению задачи или как ее уже решали?






Разместим вашу рекламу

Пиши: mail@pythondigest.ru

Нашли опечатку?

Выделите фрагмент и отправьте нажатием Ctrl+Enter.

Система Orphus