Визуализация сетевых топологий, или зачем еще сетевому инженеру 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: 20
    inventory:
        plugin: nornir.plugins.inventory.simple.SimpleInventory
        options:
            host_file: "inventory/hosts_devnet_sb_cml.yml"
            group_file: "inventory/groups.yml"

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

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


    ---
    
    internet-rtr01:
        hostname: 10.10.20.181
        platform: ios
        groups:
            - devnet-cml-lab
    
    dist-sw01:
        hostname: 10.10.20.177
        platform: nxos_ssh
        transport: ssh
        groups:
            - devnet-cml-lab

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

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


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

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


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


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




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


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

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


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


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


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


    from nornir import InitNornir
    from nornir.plugins.tasks.networking import napalm_get
    
    NORNIR_CONFIG_FILE = "nornir_config.yml"
    
    nr = InitNornir(config_file=NORNIR_CONFIG_FILE)

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


    Минутка LLDP


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

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

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


    dist-rtr01#sh lldp nei
    Capability codes:
        (R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device
        (W) WLAN Access Point, (P) Repeater, (S) Station, (O) Other
    
    Device ID           Local Intf     Hold-time  Capability      Port ID
    dist-rtr02.devnet.laGi6            120        R               Gi6
    dist-sw01.devnet.labGi4            120        B,R             Ethernet1/3
    dist-sw02.devnet.labGi5            120        B,R             Ethernet1/3
    core-rtr02.devnet.laGi3            120        R               Gi0/0/0/2
    core-rtr01.devnet.laGi2            120        R               Gi0/0/0/2
    
    Total entries displayed: 5

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


    RP/0/0/CPU0:core-rtr02#sh lldp nei
    Sun May 10 22:07:05.776 UTC
    Capability codes:
            (R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device
            (W) WLAN Access Point, (P) Repeater, (S) Station, (O) Other
    
    Device ID       Local Intf          Hold-time  Capability     Port ID
    core-rtr01.devnet.la Gi0/0/0/0           120        R               Gi0/0/0/0
    edge-sw01.devnet.lab Gi0/0/0/1           120        R               Gi0/3
    dist-rtr01.devnet.la Gi0/0/0/2           120        R               Gi3
    dist-rtr02.devnet.la Gi0/0/0/3           120        R               Gi3
    
    Total entries displayed: 4

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


    'show lldp neighbors detail' с dist-rtr01 на IOSXE
    dist-rtr01#sh lldp nei det
    ------------------------------------------------
    Local Intf: Gi6
    Chassis id: 001e.e57c.cf00
    Port id: Gi6
    Port Description: L3 Link to dist-rtr01
    System Name: dist-rtr02.devnet.lab
    
    System Description: 
    Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)
    Technical Support: http://www.cisco.com/techsupport
    Copyright (c) 1986-2019 by Cisco Systems, Inc.
    Compiled Tue 28-May-19 12:45
    
    Time remaining: 91 seconds
    System Capabilities: B,R
    Enabled Capabilities: R
    Management Addresses:
        IP: 172.16.252.18
    Auto Negotiation - not supported
    Physical media capabilities - not advertised
    Media Attachment Unit type - not advertised
    Vlan ID: - not advertised
    
    ------------------------------------------------
    Local Intf: Gi4
    Chassis id: 5254.0007.5d59
    Port id: Ethernet1/3
    Port Description: L3 link to dist-rtr01
    System Name: dist-sw01.devnet.lab
    
    System Description: 
    Cisco Nexus Operating System (NX-OS) Software 9.2(3)
    TAC support: http://www.cisco.com/tac
    Copyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved.
    
    Time remaining: 108 seconds
    System Capabilities: B,R
    Enabled Capabilities: B,R
    Management Addresses:
        IP: 10.10.20.177
        Other: 52 54 00 07 5D 59 00
    Auto Negotiation - not supported
    Physical media capabilities - not advertised
    Media Attachment Unit type - not advertised
    Vlan ID: - not advertised
    
    ------------------------------------------------
    Local Intf: Gi5
    Chassis id: 5254.0007.b7e6
    Port id: Ethernet1/3
    Port Description: L3 link to dist-rtr01
    System Name: dist-sw02.devnet.lab
    
    System Description: 
    Cisco Nexus Operating System (NX-OS) Software 9.2(3)
    TAC support: http://www.cisco.com/tac
    Copyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved.
    
    Time remaining: 97 seconds
    System Capabilities: B,R
    Enabled Capabilities: B,R
    Management Addresses:
        IP: 10.10.20.178
        Other: 52 54 00 07 FF FF 00
    Auto Negotiation - not supported
    Physical media capabilities - not advertised
    Media Attachment Unit type - not advertised
    Vlan ID: - not advertised
    
    ------------------------------------------------
    Local Intf: Gi3
    Chassis id: 02c7.9dc0.0c06
    Port id: Gi0/0/0/2
    Port Description: L3 Link to dist-rtr01
    System Name: core-rtr02.devnet.lab
    
    System Description: 
    Cisco IOS XR Software, Version 6.3.1[Default]
    Copyright (c) 2017 by Cisco Systems, Inc., IOS XRv Series
    
    Time remaining: 94 seconds
    System Capabilities: R
    Enabled Capabilities: R
    Management Addresses:
        IP: 172.16.252.26
    Auto Negotiation - not supported
    Physical media capabilities - not advertised
    Media Attachment Unit type - not advertised
    Vlan ID: - not advertised
    
    ------------------------------------------------
    Local Intf: Gi2
    Chassis id: 0288.15c0.0c06
    Port id: Gi0/0/0/2
    Port Description: L3 Link to dist-rtr01
    System Name: core-rtr01.devnet.lab
    
    System Description: 
    Cisco IOS XR Software, Version 6.3.1[Default]
    Copyright (c) 2017 by Cisco Systems, Inc., IOS XRv Series
    
    Time remaining: 110 seconds
    System Capabilities: R
    Enabled Capabilities: R
    Management Addresses:
        IP: 172.16.252.22
    Auto Negotiation - not supported
    Physical media capabilities - not advertised
    Media Attachment Unit type - not advertised
    Vlan ID: - not advertised
    
    Total entries displayed: 5

    show lldp neighbors detail c dist-sw01 на NXOS
    dist-sw01# sh lldp nei det
    Capability codes:
      (R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device
      (W) WLAN Access Point, (P) Repeater, (S) Station, (O) Other
    Device ID            Local Intf      Hold-time  Capability  Port ID  
    
    Chassis id: 5254.0007.b7e4
    Port id: Ethernet1/1
    Local Port id: Eth1/1
    Port Description: VPC Peer Link
    System Name: dist-sw02.devnet.lab
    System Description: Cisco Nexus Operating System (NX-OS) Software 9.2(3)
    TAC support: http://www.cisco.com/tac
    Copyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved.
    Time remaining: 112 seconds
    System Capabilities: B, R
    Enabled Capabilities: B, R
    Management Address: 10.10.20.178
    Management Address IPV6: not advertised
    Vlan ID: 1
    
    Chassis id: 5254.0007.b7e5
    Port id: Ethernet1/2
    Local Port id: Eth1/2
    Port Description: VPC Peer Link
    System Name: dist-sw02.devnet.lab
    System Description: Cisco Nexus Operating System (NX-OS) Software 9.2(3)
    TAC support: http://www.cisco.com/tac
    Copyright (c) 2002-2019, Cisco Systems, Inc. All rights reserved.
    Time remaining: 112 seconds
    System Capabilities: B, R
    Enabled Capabilities: B, R
    Management Address: 10.10.20.178
    Management Address IPV6: not advertised
    Vlan ID: 1
    
    Chassis id: 001e.7a2a.3900
    Port id: Gi4
    Local Port id: Eth1/3
    Port Description: L3 Link to dist-sw01
    System Name: dist-rtr01.devnet.lab
    System Description: Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)
    Technical Support: http://www.cisco.com/techsupport
    Copyright (c) 1986-2019 by Cisco Systems, Inc.
    Compiled Tue 28-May-19 12:45
    Time remaining: 109 seconds
    System Capabilities: B, R
    Enabled Capabilities: R
    Management Address: 172.16.252.2
    Management Address IPV6: not advertised
    Vlan ID: not advertised
    
    Chassis id: 001e.e57c.cf00
    Port id: Gi4
    Local Port id: Eth1/4
    Port Description: L3 Link to dist-sw01
    System Name: dist-rtr02.devnet.lab
    System Description: Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)
    Technical Support: http://www.cisco.com/techsupport
    Copyright (c) 1986-2019 by Cisco Systems, Inc.
    Compiled Tue 28-May-19 12:45
    Time remaining: 108 seconds
    System Capabilities: B, R
    Enabled Capabilities: R
    Management Address: 172.16.252.6
    Management Address IPV6: not advertised
    Vlan ID: not advertised
    
    Total entries displayed: 4

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


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


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

    Файл хостов inventory/hosts_devnet_sb_cml.yml
    ---
    
    internet-rtr01:
        hostname: 10.10.20.181
        platform: ios
        site: devnet_sandbox
        groups:
            - devnet-cml-lab
    
    edge-sw01:
        hostname: 10.10.20.172
        platform: ios
        site: devnet_sandbox
        groups:
            - devnet-cml-lab
    
    core-rtr01:
        # доступ на устройстве заблокирован для теста
        hostname: 10.10.20.173
        platform: iosxr
        groups:
            - devnet-cml-lab
    
    core-rtr02:
        # доступ на устройстве заблокирован для теста
        hostname: 10.10.20.174
        platform: iosxr
        groups:
            - devnet-cml-lab
    
    dist-rtr01:
        hostname: 10.10.20.175
        platform: ios
        groups:
            - devnet-cml-lab
    
    dist-rtr02:
        hostname: 10.10.20.176
        platform: ios
        groups:
            - devnet-cml-lab
    
    dist-sw01:
        hostname: 10.10.20.177
        platform: nxos_ssh
        transport: ssh
        groups:
            - devnet-cml-lab
    
    dist-sw02:
        hostname: 10.10.20.178
        platform: nxos_ssh
        transport: ssh
        groups:
            - devnet-cml-lab
    

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


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

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


    def get_host_data(task):
        """Nornir Task для сбора данных с целевых устройств."""
        task.run(
            task=napalm_get,
            getters=['facts', 'lldp_neighbors_detail']
        )
    
    # Запустим таск на всех устройствах в инвентори.
    # Результат сохраним в переменную для дальнейшего разбора.
    get_host_data_result = nr.run(get_host_data)

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


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


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


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

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


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

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


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


    Содержимое объекта результата с dist-rtr01
    >>> get_host_data_result['dist-rtr01'][1].result
    {'facts': {'uptime': 6120, 'vendor': 'Cisco', 'os_version': 'Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'serial_number': '9JDCOVUDSWN', 'model': 'CSR1000V', 'hostname': 'dist-rtr01', 'fqdn': 'dist-rtr01.devnet.lab', 'interface_list': ['GigabitEthernet1', 'GigabitEthernet2', 'GigabitEthernet3', 'GigabitEthernet4', 'GigabitEthernet5', 'GigabitEthernet6', 'Loopback0']}, 'lldp_neighbors_detail': {'GigabitEthernet6': [{'remote_chassis_id': '001e.e57c.cf00', 'remote_port': 'Gi6', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'dist-rtr02.devnet.lab', 'remote_system_description': 'Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}], 'GigabitEthernet4': [{'remote_chassis_id': '5254.0007.5d59', 'remote_port': 'Ethernet1/3', 'remote_port_description': 'L3 link to dist-rtr01', 'remote_system_name': 'dist-sw01.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}], 'GigabitEthernet5': [{'remote_chassis_id': '5254.0007.b7e6', 'remote_port': 'Ethernet1/3', 'remote_port_description': 'L3 link to dist-rtr01', 'remote_system_name': 'dist-sw02.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}], 'GigabitEthernet3': [{'remote_chassis_id': '02c7.9dc0.0c06', 'remote_port': 'Gi0/0/0/2', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'core-rtr02.devnet.lab', 'remote_system_description': 'Cisco IOS XR Software, Version 6.3.1[Default]', 'remote_system_capab': ['router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}], 'GigabitEthernet2': [{'remote_chassis_id': '0288.15c0.0c06', 'remote_port': 'Gi0/0/0/2', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'core-rtr01.devnet.lab', 'remote_system_description': 'Cisco IOS XR Software, Version 6.3.1[Default]', 'remote_system_capab': ['router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}]}}

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

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


    Соседи dist-rtr01
    >>> for neighbor in get_host_data_result['dist-rtr01'][1].result['lldp_neighbors_detail'].items():
    ...     print(neighbor)
    ...     print('\n')
    ... 
    ('GigabitEthernet6', [{'remote_chassis_id': '001e.e57c.cf00', 'remote_port': 'Gi6', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'dist-rtr02.devnet.lab', 'remote_system_description': 'Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}])
    
    ('GigabitEthernet4', [{'remote_chassis_id': '5254.0007.5d59', 'remote_port': 'Ethernet1/3', 'remote_port_description': 'L3 link to dist-rtr01', 'remote_system_name': 'dist-sw01.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}])
    
    ('GigabitEthernet5', [{'remote_chassis_id': '5254.0007.b7e6', 'remote_port': 'Ethernet1/3', 'remote_port_description': 'L3 link to dist-rtr01', 'remote_system_name': 'dist-sw02.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}])
    
    ('GigabitEthernet3', [{'remote_chassis_id': '02c7.9dc0.0c06', 'remote_port': 'Gi0/0/0/2', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'core-rtr02.devnet.lab', 'remote_system_description': 'Cisco IOS XR Software, Version 6.3.1[Default]', 'remote_system_capab': ['router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}])
    
    ('GigabitEthernet2', [{'remote_chassis_id': '0288.15c0.0c06', 'remote_port': 'Gi0/0/0/2', 'remote_port_description': 'L3 Link to dist-rtr01', 'remote_system_name': 'core-rtr01.devnet.lab', 'remote_system_description': 'Cisco IOS XR Software, Version 6.3.1[Default]', 'remote_system_capab': ['router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}])

    Соседи dist-sw01
    >>> for neighbor in get_host_data_result['dist-sw01'][1].result['lldp_neighbors_detail'].items():
    ...     print(neighbor)
    ...     print('\n')
    ... 
    ('Ethernet1/1', [{'remote_chassis_id': '5254.0007.b7e4', 'remote_port': 'Ethernet1/1', 'remote_port_description': 'VPC Peer Link', 'remote_system_name': 'dist-sw02.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}])
    
    ('Ethernet1/2', [{'remote_chassis_id': '5254.0007.b7e5', 'remote_port': 'Ethernet1/2', 'remote_port_description': 'VPC Peer Link', 'remote_system_name': 'dist-sw02.devnet.lab', 'remote_system_description': 'Cisco Nexus Operating System (NX-OS) Software 9.2(3)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['bridge', 'router'], 'parent_interface': ''}])
    
    ('Ethernet1/3', [{'remote_chassis_id': '001e.7a2a.3900', 'remote_port': 'Gi4', 'remote_port_description': 'L3 Link to dist-sw01', 'remote_system_name': 'dist-rtr01.devnet.lab', 'remote_system_description': 'Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}])
    
    ('Ethernet1/4', [{'remote_chassis_id': '001e.e57c.cf00', 'remote_port': 'Gi4', 'remote_port_description': 'L3 Link to dist-sw01', 'remote_system_name': 'dist-rtr02.devnet.lab', 'remote_system_description': 'Cisco IOS Software [Gibraltar], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.11.1b, RELEASE SOFTWARE (fc2)', 'remote_system_capab': ['bridge', 'router'], 'remote_system_enable_capab': ['router'], 'parent_interface': ''}])

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


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


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

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


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

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


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


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


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


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

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


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


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

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


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


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

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


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


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

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


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

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


    icon_capability_map = {
        'router': 'router',
        'switch': 'switch',
        'bridge': 'switch',
        'station': 'host'
    }
    
    icon_model_map = {
        'CSR1000V': 'router',
        'Nexus': 'switch',
        'IOSXRv': 'router',
        'IOSv': 'switch',
        '2901': 'router',
        '2911': 'router',
        '2921': 'router',
        '2951': 'router',
        '4321': 'router',
        '4331': 'router',
        '4351': 'router',
        '4421': 'router',
        '4431': 'router',
        '4451': 'router',
        '2960': 'switch',
        '3750': 'switch',
        '3850': 'switch',
    }
    
    def get_icon_type(device_cap_name, device_model=''):
        """
        Функция для определения типа пиктограммы устройства.
        Приоритет имеет маппинг LLDP capabilities.
        Если по ним определить тип пиктограммы не удалось,
        делается проверка по модели устройства.
        При отсутствии результата возвращается тип по умолчанию 'unknown'.
        """
        if device_cap_name:
            icon_type = icon_capability_map.get(device_cap_name)
            if icon_type:
                return icon_type
        if device_model:
            # Проверяется вхождение подстроки из ключей icon_model_map
            # В строке модели устройства до первого совпадения
            for model_shortname, icon_type in icon_model_map.items():
                if model_shortname in device_model:
                    return icon_type
        return 'unknown'
    
    def generate_topology_json(*args):
        """
        Генератор JSON-объекта топологии.
        На вход принимает сет из всех обнаруженных в топологии хостов,
        словарь обнаруженных LLDP capabilities с ключами в виде
        хостнеймов, список уникальных связностей между хостами и словарь
        с дополнительными данными об устройствах с ключами в виде хостнеймов.
        """
        discovered_hosts, interconnections, lldp_capabilities_dict, facts = args
        host_id = 0
        host_id_map = {}
        topology_dict = {'nodes': [], 'links': []}
        for host in discovered_hosts:
            device_model = 'n/a'
            device_serial = 'n/a'
            device_ip = 'n/a'
            if facts.get(host):
                device_model = facts[host].get('model', 'n/a')
                device_serial = facts[host].get('serial_number', 'n/a')
                device_ip = facts[host].get('nr_ip', 'n/a')
            host_id_map[host] = host_id
            topology_dict['nodes'].append({
                'id': host_id,
                'name': host,
                'primaryIP': device_ip,
                'model': device_model,
                'serial_number': device_serial,
                'icon': get_icon_type(
                    lldp_capabilities_dict.get(host, ''),
                    device_model
                )
            })
            host_id += 1
        link_id = 0
        for link in interconnections:
            topology_dict['links'].append({
                'id': link_id,
                'source': host_id_map[link[0][0]],
                'target': host_id_map[link[1][0]],
                'srcIfName': if_shortname(link[0][1]),
                'srcDevice': link[0][0],
                'tgtIfName': if_shortname(link[1][1]),
                'tgtDevice': link[1][0],
            })
            link_id += 1
        return topology_dict

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


    import json
    
    OUTPUT_TOPOLOGY_FILENAME = 'topology.js'
    TOPOLOGY_FILE_HEAD = "\n\nvar topologyData = "
    
    def write_topology_file(topology_json, header=TOPOLOGY_FILE_HEAD, dst=OUTPUT_TOPOLOGY_FILENAME):
        with open(dst, 'w') as topology_file:
            topology_file.write(header)
            topology_file.write(json.dumps(topology_json, indent=4, sort_keys=True))
            topology_file.write(';')
    
    TOPOLOGY_DICT = generate_topology_json(*TOPOLOGY_DETAILS)
    write_topology_file(TOPOLOGY_DICT)

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

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



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



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


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


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


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

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


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

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


    CACHED_TOPOLOGY_FILENAME = 'cached_topology.json'
    
    def write_topology_cache(topology_json, dst=CACHED_TOPOLOGY_FILENAME):
        with open(dst, 'w') as cached_file:
            cached_file.write(json.dumps(topology_json, indent=4, sort_keys=True))
    
    def read_cached_topology(filename=CACHED_TOPOLOGY_FILENAME):
        if not os.path.exists(filename):
            return {}
        if not os.path.isfile(filename):
            return {}
        cached_topology = {}
        with open(filename, 'r') as file:
            try:
                cached_topology = json.loads(file.read())
            except:
                return {}
        return cached_topology

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


    1. Из словарей текущей и кэшированной топологии извлечем необходимые для сравнения атрибуты нод и линков.
      Формат для нод:
      (исходные данные ноды с полным набором атрибутов, (хостнейм,))
      Формат для линков:
      (исходные данные линка с полным набором атрибутов, (хостнейм_источника, порт источника), (хостнейм назначения, порт_назначения))
      Формат выбран для возможности добавления атрибутов для сравнения и более гибкого поиска изменений.
    2. По этим объектам выполним поиск изменений по нодам и линкам (с учетом перестановок источник-назначение).
      Изменения по нодам будут записаны в два словаря формата:
      • diff_nodes = {'added': [], 'deleted': []}
      • diff_links = {'added': [], 'deleted': []}
    3. В ходе поиска изменений выполним слияние текущей и кэшированной топологии.
      Результирующая топология будет содержаться в словаре diff_merged_topology.
      Для визуализации изменений при обнаружении удаленных и добавленных нод и линков исходные данные будем расширять дополнительными атрибутами с говорящим названием is_new и is_dead.
      Для удаленных нод для наглядности также введем отдельный тип пиктограммы 'dead_node' (в NeXt UI учтем это ниже).

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


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

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


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

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


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

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


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



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


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



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

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


    $ python3.7 generate_topology.py 
    Для просмотра топологии откройте файл main.html
    
    Обнаружены изменения в топологии:
    
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    Новые сетевые устройства:
    vvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    Имя устройства: dist-rtr01.devnet.lab
    
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    Удаленные сетевые устройства:
    vvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    Имя устройства: edge-sw01.devnet.lab
    
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    Новые соединения между устройствами:
    vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    От dist-rtr01.devnet.lab(Gi3) к core-rtr02.devnet.lab(Gi0/0/0/2)
    От dist-rtr01.devnet.lab(Gi4) к dist-sw01.devnet.lab(Eth1/3)
    От dist-rtr01.devnet.lab(Gi6) к dist-rtr02.devnet.lab(Gi6)
    От dist-rtr01.devnet.lab(Gi5) к dist-sw02.devnet.lab(Eth1/3)
    От dist-rtr01.devnet.lab(Gi2) к core-rtr01.devnet.lab(Gi0/0/0/2)
    
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    Удаленные соединения между устройствами:
    vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    От edge-sw01.devnet.lab(Gi0/2) к core-rtr01.devnet.lab(Gi0/0/0/1)
    От edge-sw01.devnet.lab(Gi0/3) к core-rtr02.devnet.lab(Gi0/0/0/1)
    
    Для просмотра топологии с визуализацией изменений откройте файл diff_page.html
    Либо откройте файл main.html и нажмите кнопку 'Показать визуализацию изменений

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


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


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


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


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


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

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


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

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


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


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

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


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



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


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


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


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

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


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

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


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

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



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


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


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


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


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


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


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


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

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


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

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


    NX_LAYER_SORT_ORDER = (
        'undefined',
        'outside',
        'edge-switch',
        'edge-router',
        'core-router',
        'core-switch',
        'distribution-router',
        'distribution-switch',
        'leaf',
        'spine',
        'access-switch'
    )
    
    def get_node_layer_sort_preference(device_role):
        for i, role in enumerate(NX_LAYER_SORT_ORDER, start=1):
            if device_role == role:
                return i
        return 1

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


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


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

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


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

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


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

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



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


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


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

    Выводы


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


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


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


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


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

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 8

      +1

      За пример работы с Next UI спасибо. Я наверное за вас голосовал в devnet cisco :) Но сама задача, построения схемы на основе cdp/lldp мне кажется немного не нужной. Я когда-то тоже написал скрипт, который на основе cdp строил схему, правда на основе Graphviz, получалось не так красиво, как c использованием Next UI. Но проблема, что я им практически не пользовался, задачка больше игровая, чем для применения в реальности. Дело в том, что для enterprise это не нужно, а нужен хороший источник sourth of truth, типа netbox или другой откуда брать информацию о линках и проверять их на правильность, используя похожий как увас скрипт. И должны быть схемы всей сети, нормальные, детальные с различными срезами и уровнями. Для интегратора тоже не особо нужен данный инструментарий, так как не всегда возможно запустить lldp/cdp у заказчика, а если и получается, то это присылается ввиде текстовых файлов, которые я обрабатываю и создаю на основе их excel/csv таблицы для анализа, если же строить схемы, иногда получается слишком много соединений и все это трудно анализировать на схеме, в табличном виде лучше. У меня есть другой скрипт(не совсем доработанный), который строит логическую схему сети третьего уровня, она более сложная задачка сама по себе, но им я пользуюсь более часто, так как иногда присылают конфиги и их нужно проанализировать и я пытаюсь их пропустить через скрипт, для быстрого построения логической схемы третьего уровня, но только в том случае, если у заказчика нет нормальных схем своей сети.

        0
        Спасибо за развернутый ответ!
        Но сама задача, построения схемы на основе cdp/lldp мне кажется немного не нужной.
        иногда получается слишком много соединений и все это трудно анализировать на схеме, в табличном виде лучше
        Как и всегда, не существует универсальных решений. Инструменты хороши в совокупности, и схемы с таблицами обычно дополняют друг друга. LLPD/CDP же дает базовое понимание топологии, поверх него уже можно накладывать дополнительные слои, обогащать схему деталями по потребностям и визуализировать более сложные кейсы (например, визуализацию путей из моей прошлой статьи).

        Для случаев с большим количеством линков и нод в NeXt UI, кстати, есть хороший функционал Node Sets (наборы нод), с помощью него можно группировать отдельные ноды и сворачивать их в одну пиктограмму (и разворачивать по клику) при визуализации.
        нужен хороший источник sourth of truth, типа netbox или другой откуда брать информацию о линках и проверять их на правильность, используя похожий как увас скрипт. И должны быть схемы всей сети, нормальные, детальные с различными срезами и уровнями.
        С необходимостью SoT в той или иной реализации полностью согласен. И идею с визуализацией можно развить и в этой плоскости, формируя объект топологии на основании связей и данных в Netbox, доставаемых через API. Экспериментами в этом направлении, скорее всего, и займусь. Если сообществу будет интересно, может, опубликую отдельную статью.
        С учетом того, что у Netbox открытый код, можно даже интегрировать вкладку с визуализацией напрямую в его интерфейс (например, в /dcim/sites/$site_slug).
        У меня есть другой скрипт(не совсем доработанный), который строит логическую схему сети третьего уровня, она более сложная задачка сама по себе, но им я пользуюсь более часто
        Было бы интересно почитать статью с описанием подобного опыта. :)
          0
          идею с визуализацией можно развить и в этой плоскости, формируя объект топологии на основании связей и данных в Netbox, доставаемых через API. Экспериментами в этом направлении, скорее всего, и займусь.
          В результате экспериментов в свободное время родилась бета-версия плагина для интеграции топологий напрямую в Netbox (функционал плагинов добавили недавно в версии 2.8.0).
          Код выложил на GitHub, если кого-то заинтересует.
        0
        Спасибо за приведенные инструменты и разбор!
          0

          Lldp + snmp.

            0
            Этот набор, увы, оказывается не всегда достаточным в случае разношерстного оборудования(
            На практике лично мне пришлось делать модули, которые собирают lldp, всякие проприетарные протоколы (cdp, edp итд), fdb таблицу в влане управления и даже stp. Причём не только по snmp, что-то приходится забирать по ssh и даже по telnet. Каждый модуль пишет собранные данные в отдельную для каждого способа/протокола табличку в постгресе. Таблички регулярно (несколько раз в минуту) обрабатывает сервис на Go, формирует топологию, определяет её разницу с текущей и апдейтит итоговую таблицу связности.
            И вот только в таком виде топология для мультивендорной сети становится практически полностью известной. Ну и можно исторические данные сохранять бонусом — иногда полезно в прошлое посмотреть.
            0
            А клиентские устройства как лучше получать с коммутаторов (Cisco), чтобы добавить на эту карту?
              0
              Это сильно зависит от применяемых на доступе технологий, типов клиентских устройств и т.д.
              Для корпоративных доменных сетей минимально можно смотреть MAC-адреса на портах, по ним из ARP узнавать IP-адреса, а из IP-адресов пытаться ресолвить хостнеймы.
              При внедренном 802.1x предсказуемость результата становится уже выше. И так с каждым дополнительным источником информации об устройствах.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое