Хабрахабр

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

Привет, Хабр! Эта статья написана по мотивам решения задания на недавно прошедшем онлайн-марафоне 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: 20inventory: 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

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

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

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

from nornir import InitNornirfrom 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 neiCapability 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 IDdist-rtr02.devnet.laGi6 120 R Gi6dist-sw01.devnet.labGi4 120 B,R Ethernet1/3dist-sw02.devnet.labGi5 120 B,R Ethernet1/3core-rtr02.devnet.laGi3 120 R Gi0/0/0/2core-rtr01.devnet.laGi2 120 R Gi0/0/0/2 Total entries displayed: 5

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

RP/0/0/CPU0:core-rtr02#sh lldp neiSun May 10 22:07:05.776 UTCCapability 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 IDcore-rtr01.devnet.la Gi0/0/0/0 120 R Gi0/0/0/0edge-sw01.devnet.lab Gi0/0/0/1 120 R Gi0/3dist-rtr01.devnet.la Gi0/0/0/2 120 R Gi3dist-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: Gi6Chassis id: 001e.e57c.cf00Port id: Gi6Port Description: L3 Link to dist-rtr01System 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/techsupportCopyright (c) 1986-2019 by Cisco Systems, Inc.Compiled Tue 28-May-19 12:45 Time remaining: 91 secondsSystem Capabilities: B,REnabled Capabilities: RManagement Addresses: IP: 172.16.252.18Auto Negotiation - not supportedPhysical media capabilities - not advertisedMedia Attachment Unit type - not advertisedVlan ID: - not advertised ------------------------------------------------Local Intf: Gi4Chassis id: 5254.0007.5d59Port id: Ethernet1/3Port Description: L3 link to dist-rtr01System Name: dist-sw01.devnet.lab System Description: Cisco Nexus Operating System (NX-OS) Software 9.2(3)TAC support: http://www.cisco.com/tacCopyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved. Time remaining: 108 secondsSystem Capabilities: B,REnabled Capabilities: B,RManagement Addresses: IP: 10.10.20.177 Other: 52 54 00 07 5D 59 00Auto Negotiation - not supportedPhysical media capabilities - not advertisedMedia Attachment Unit type - not advertisedVlan ID: - not advertised ------------------------------------------------Local Intf: Gi5Chassis id: 5254.0007.b7e6Port id: Ethernet1/3Port Description: L3 link to dist-rtr01System Name: dist-sw02.devnet.lab System Description: Cisco Nexus Operating System (NX-OS) Software 9.2(3)TAC support: http://www.cisco.com/tacCopyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved. Time remaining: 97 secondsSystem Capabilities: B,REnabled Capabilities: B,RManagement Addresses: IP: 10.10.20.178 Other: 52 54 00 07 FF FF 00Auto Negotiation - not supportedPhysical media capabilities - not advertisedMedia Attachment Unit type - not advertisedVlan ID: - not advertised ------------------------------------------------Local Intf: Gi3Chassis id: 02c7.9dc0.0c06Port id: Gi0/0/0/2Port Description: L3 Link to dist-rtr01System 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 secondsSystem Capabilities: REnabled Capabilities: RManagement Addresses: IP: 172.16.252.26Auto Negotiation - not supportedPhysical media capabilities - not advertisedMedia Attachment Unit type - not advertisedVlan ID: - not advertised ------------------------------------------------Local Intf: Gi2Chassis id: 0288.15c0.0c06Port id: Gi0/0/0/2Port Description: L3 Link to dist-rtr01System 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 secondsSystem Capabilities: REnabled Capabilities: RManagement Addresses: IP: 172.16.252.22Auto Negotiation - not supportedPhysical media capabilities - not advertisedMedia Attachment Unit type - not advertisedVlan ID: - not advertised Total entries displayed: 5

show lldp neighbors detail c dist-sw01 на NXOS

dist-sw01# sh lldp nei detCapability codes: (R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device (W) WLAN Access Point, (P) Repeater, (S) Station, (O) OtherDevice ID Local Intf Hold-time Capability Port ID Chassis id: 5254.0007.b7e4Port id: Ethernet1/1Local Port id: Eth1/1Port Description: VPC Peer LinkSystem Name: dist-sw02.devnet.labSystem Description: Cisco Nexus Operating System (NX-OS) Software 9.2(3)TAC support: http://www.cisco.com/tacCopyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved.Time remaining: 112 secondsSystem Capabilities: B, REnabled Capabilities: B, RManagement Address: 10.10.20.178Management Address IPV6: not advertisedVlan ID: 1 Chassis id: 5254.0007.b7e5Port id: Ethernet1/2Local Port id: Eth1/2Port Description: VPC Peer LinkSystem Name: dist-sw02.devnet.labSystem Description: Cisco Nexus Operating System (NX-OS) Software 9.2(3)TAC support: http://www.cisco.com/tacCopyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved.Time remaining: 112 secondsSystem Capabilities: B, REnabled Capabilities: B, RManagement Address: 10.10.20.178Management Address IPV6: not advertisedVlan ID: 1 Chassis id: 001e.7a2a.3900Port id: Gi4Local Port id: Eth1/3Port Description: L3 Link to dist-sw01System Name: dist-rtr01.devnet.labSystem 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/techsupportCopyright (c) 1986-2019 by Cisco Systems, Inc.Compiled Tue 28-May-19 12:45Time remaining: 109 secondsSystem Capabilities: B, REnabled Capabilities: RManagement Address: 172.16.252.2Management Address IPV6: not advertisedVlan ID: not advertised Chassis id: 001e.e57c.cf00Port id: Gi4Local Port id: Eth1/4Port Description: L3 Link to dist-sw01System Name: dist-rtr02.devnet.labSystem 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/techsupportCopyright (c) 1986-2019 by Cisco Systems, Inc.Compiled Tue 28-May-19 12:45Time remaining: 108 secondsSystem Capabilities: B, REnabled Capabilities: RManagement Address: 172.16.252.6Management Address IPV6: not advertisedVlan 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_resultAggregatedResult (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: Falseedge-sw01 failed: Falsecore-rtr01 failed: Truecore-rtr02 failed: Truedist-rtr01 failed: Falsedist-rtr02 failed: Falsedist-sw01 failed: Falsedist-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.

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

Для добавления подписей к линкам расширим стандартный класс 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.pngtopo.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

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть