Визуализация сетевых топологий, или зачем еще сетевому инженеру Python #2
- среда, 13 мая 2020 г. в 00:29:10
Привет, Хабр! Эта статья написана по мотивам решения задания на недавно прошедшем онлайн-марафоне DevNet от Cisco. Участникам предлагалось автоматизировать анализ и визуализацию произвольной сетевой топологии и, опционально, происходящих в ней изменений.
Задача является не самой тривиальной, и в блогосфере встречается довольно мало статей на эту тему. Ниже представляю разбор собственной реализации, а также описание используемых инструментов и подходов.
Всем заинтересовавшимся добро пожаловать под кат!
*Инемного Javascript.
Данная статья не претендует на всеобщий охват вариантов и проблематики визуализации сетей, но при описании частного случая руководствуется и общими соображениями.
Все нижесказанное и вышесказанное является личным оценочным суждением автора, если не указано обратное.
Приведенный код распространяется под лицензией MIT и не дает гарантий никакого рода.
Обсуждение и конструктивная критика всячески приветствуются.
Если вы заметили опечатку, пожалуйста, воспользуйтесь комбинацией Ctrl+Enter или ⌘+Enter для ее отправки автору.
В исходном виде задание выглядело следующим образом:
Имеется сеть, состоящая из различных L2/L3 сетевых устройств под управлением IOS/IOSXE. Известен список IP-адресов управления для всех устройств, все устройства доступны по IP, для каждого устройства есть доступ для выполнения show-команд. Для вас доступны любые способы сбора информации, но поверьте, вам вряд ли нужен SNMP. Хотя мы не в праве ограничивать вашу фантазию.
Основная задача: определить физическую топологию соединений устройств по LLDP и визуализировать ее в удобном для восприятия человеком виде (да, нам всем удобны графические схемы). Выбрать формат хранения данных о топологии в удобном для машинного анализа виде (да, машинам неудобны графические схемы).
На рисунке c топологией должны быть отображены:
• Пиктограмма каждого устройства (коммутаторы и маршрутизаторы могут быть отмечены одинаковым типом пиктограммы).
• Hostname устройства.
• Название каждого интерфейса (можно в сокращённом формате, например, вместо GigabitEthernet0/0 — G0/0).
Допускается реализация фильтров, ограничивающих (скрывающих) информацию.
Дополнительная задача: определить изменения в топологии (сравнив текущую и предыдущую версии) и визуализировать их в удобном для восприятия человеком виде.
На входе — IP-адреса и учетные данные для доступа на оборудование, на выходе — готовая топология. Огромное пространство для экспериментов и вариантов где-то посередине.
Для себя дополнительно ввел следующие директивы для выбора инструментов реализации:
Перед изобретением велосипеда всегда стоит поинтересоваться, не решена ли задача до нас. Вот в этой статье на Хабре довольно хорошо расписаны существующие продукты для построения сетевых карт. Ожидаемо, ничего напрямую подходящего, а модули визуализации зачастую идут как составная часть большой системы мониторинга, что значительно уменьшает возможные сценарии переиспользования и кастомизации.
Абстрактную задачу на автоматизированную визуализацию сетевых топологий можно разделить примерно на следующие уровни и этапы:
Пройдемся по ним снизу вверх с оглядкой на имеющиеся требования:
Суммируя, далеко не полный список вариантов:
В итоге выбор пал на следующий стек:
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 является сторонним 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-скрипте и начать работу с ним.
Для локального использования и тестирования достаточно скачать исходники с 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 в 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 устройства обмениваются с прямыми соседями фреймами, содержащими набор 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-автоматизации.
В качестве обходного пути будем ориентироваться на детальный вывод с каждого из устройств.
Для примера:
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
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) доступ будет закрыт.
Таким образом будут покрыты сценарии:
---
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:
Сбор данных обернем в функцию-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
Выглядит ожидаемо.
Полная структура результата для примера:
>>> 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': ''}]}}
>>> 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'ом по структурам данных.
Сверим соседства:
>>> 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': ''}])
>>> 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 и фактам.
Для сведения всех данных за уникальный идентификатор устройства примем в порядке убывания приоритета:
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))
Стоит также учесть, что:
Для однозначной идентификации будем транслировать их в полный формат. И добавим функцию для трансляции в сокращенный вид для дальнейшего удобства визуализации.
Для автоматического выбора правильной пиктограммы устройства при визуализации будем учитывать его 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_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>
Ранее мы уже написали необходимые обработчики результата и получили базовое представление топологии в структурах данных 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)
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 форма с атрибутами, кототорые мы передали в объекты нод в топологию:
Уже неплохо, но можно лучше. Вернемся к отображению позже, а пока займемся второй частью задачи.
По условию была дополнительная задача на визуализацию изменений в топологии.
Решим и ее. Сперва, введем некоторые дополнения:
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
Для поиска и визуализации изменений в топологии:
Реализуем обозначенную логику в коде:
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.
Предыдущая версия окажется закэшированной, а текущей будет такая:
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.png
topo.registerIcon("dead_node", "img/dead_node.png", 49, 49);
Теперь с учетом проделанных изменений можем открыть diff_page.html и посмотреть на визуализацию сгенерированных выше изменений:
Наглядно. Как считаете?
Меню по умолчанию выдает много лишней служебной информации.
Оно также может быть кастомизировано в NeXt UI.
Заложим в кастомную версию:
Для этого расширим стандартный класс 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
В первую очередь, спасибо всем, кто дочитал до конца.
В статье постарался изложить и прокомментировать основные этапы разработки итогового варианта, а также сделать обзор используемых инструментов. Решение на марафоне заняло первое место и, что главное, является кросплатформенным и имеет потенциал к дальнейшему расширению и интеграции не только в лабораторных условиях.
Надеюсь, это может быть кому-то полезно.
Буду рад обратной связи и конструктивной критике. Что можно было бы изменить или улучшить?
Как бы вы подошли к решению задачи или как ее уже решали?