Добрый день, Habr. Меня зовут Сергей, я старший эксперт в компании Ростелеком. В зоне моей ответственности эксплуатация сетевого оборудования компании (в основном маршрутизаторы и коммутаторы). Когда счет устройств, с которыми необходимо работать, идет на тысячи, обойтись без автоматизации решительно невозможно. И значительная часть моей деятельности - автоматизация работы с оборудованием различных моделей и производителей, для чего, как правило, я использую скрипты на Python. В нашей компании используется оборудование различных производителей, но значительная доля оборудования - Juniper. Поэтому, в данной статье я хотел бы описать возможные подходы к автоматизации сбора информации и обслуживанию оборудования именно данного производителя.
Отличительной чертой оборудования Juniper является то, что и коммутаторы, и маршрутизаторы работают под управлением одной и той же операционной системы JunOS. Конечно, доступные функции и команды отличаются в зависимости от модели, но общая операционная система позволяет использовать один и тот же подход в работе со всем спектром оборудования вендора. Так, вне зависимости от модели применяется единый подход внесения изменений в конфигурацию, когда изменения применяются только после их фиксации командой commit. У некоторых других производителей это поведение различается от модели к модели, что усложняет процесс автоматизации.

В качестве подопытного для демонстрации практических действий будет выступать коммутатор Juniper EX2200.

Библиотека Netmiko

Стандартный алгоритм работы с оборудованием Juniper (да и не только с ним) без автоматизации выглядит очень просто: подключаемся к устройству по протоколу SSH (вряд ли кто-то сейчас будет использовать для этого Telnet, если только это не единственный возможный вариант), вводим в консоли команды и туда же получаем ответ устройства. Поэтому логично, что самый простой способ автоматизации работы - автоматизация подключения к устройству и отправки на него команд. И для этого в Python есть отличная библиотека - netmiko. Она не только позволяет работать с оборудованием через различные протоколы (SSH, telnet, serial), но и для большого количества известного оборудования берет на себя заботу о поиске приглашений командной строки, отключению постраничного вывода, переходу в режим конфигурации и обратно и многом другом. Да, для некоторых вендоров различные модели ведут себя несколько по-разному и требуется "обработка напильником", но для Juniper все работает отлично.

Установить библиотеку можно, например, так:
pip install netmiko
Чтобы сразу перейти к практике, вот пример подключения к устройству и вывод результата работы команды show version

import netmiko

dev_info = {
    'device_type': 'juniper_junos',
    'ip': '192.168.0.151',
    'username': 'netconf',
    'password': 'NetConf',
    'port': 22,
    # "use_keys": True,
    # "key_file": ,
}

try:
    dev = netmiko.ConnectHandler(**dev_info)
    print(dev.send_command('show version'))
except netmiko.NetmikoBaseException as e:
    print(e)

Указываем необходимые данные для подключения в виде IP, логина и пароля (можно указать ключ для подключения как в закомментированных строках), а так же тип устройства, к которому подключаемся. Указание на тип устройства позволит библиотеке правильно обрабатывать вывод (например, отсекать от информации баннер в виде {master:0}, который добавляет juniper при ответе) и выполнить подготовительные команды:

set cli screen-width 511 - устанавливает ширину экрана
set cli complete-on-space off - отключает автоматическую подстановку полной команды
set cli screen-length 0 - отключает постраничный вывод

Отдельно хочу обратить внимание на вторую команду. Стандартное поведение junos - при вводе части команды и пробела, система автоматически дополняет команду до полного названия. А в ответе от устройства в первой строке библиотека ожидает увидеть ту команду, которую она отправила на сервер. Если бы мы не отключили данную опцию, при вводе сокращенной команды, например sho ver вместо show version, отклик от устройства не совпадал бы с переданным запросом и выскочило бы исключение по таймауту. Но, важно помнить, что наше поведение спасает от сокращенного ввода команд, но не может спасти, например, от нескольких пробелов. Если в пример выше мы вызовем dev.send_command('show version'), то получим сообщение:

Pattern not detected: 'show\\ \\ \\ \\ \\ \\ version' in output.

Things you might try to fix this:
1. Adjust the regex pattern to better identify the terminating string. Note, in
many situations the pattern is automatically based on the network device's prompt.
2. Increase the read_timeout to a larger value.

You can also look at the Netmiko session_log or debug log for more information.

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

На этом, в части получения информации, можно было бы закончить. Смотрим на устройстве вывод конкретной команды, определяем, где в выводе находится интересующая нас информация, и выполняем команду с помощью send_command. А дальше в дело вступает самый обычный Python со всеми его возможностями по обработке текста, которые мы используем для разбора ответа от устройства. Но иногда удобнее воспользоваться дополнительными возможностями, которые предоставляет нам JunOS. К примеру, требуется получить значение какого-то конкретного параметра, а выбирать его из большой простыни вывода не очень удобно. Тогда мы можем попросить JunOS выдать ответ уже в структурированном виде, который и будем в дальнейшем разбирать. Можно получить вывод в формате XML, для чего просто добавить после команды | display xml. К примеру, если мы хотим получить список всех маршрутизаторов из протокола IS-IS, то можем сделать этот так (очевидно, что тут уже нам нужен будет маршрутизатор):

import lxml.etree as etree

xml_data = dev.send_command('show isis hostname | display xml')
root = etree.fromstring(xml_data)

# Find all 'isis-hostname' elements
isis_hostnames = root.findall('.//{*}isis-hostname')

# Create a list to hold the system-id and system-name pairs
isis_data = [(isis_host.find('{*}system-id').text, isis_host.find('{*}system-name').text)
             for isis_host in isis_hostnames]

А начиная с 15 версии JunOS так же есть возможность вывода в JSON добавив | display json.

Внесение изменений

Для изменения конфигурации в библиотеке netmiko у объекта BaseConnection имеется метод send_config_set. Метод принимает одну или список команд конфигурации, проверяет, активен ли режим конфигурирования, если нет, заходит в него и выполняет переданные команды. Попробуем изменить, к примеру, описание одного из портов.

    cmd = 'set interfaces ge-0/0/10 description "netmiko test"'
    print(dev.send_config_set(cmd))

На выходе получим следующее:

configure
Entering configuration mode

{master:0}[edit]
netconf@TEST-EX2200#set interfaces ge-0/0/10 description "netmiko test"

{master:0}[edit]
netconf@TEST-EX2200# exit configuration-mode
The configuration has been changed but not committed
Exiting configuration mode

{master:0}
netconf@TEST-EX2200>

Если теперь посмотреть описание этого порта на устройстве, то мы увидим что оно совершенно не изменилось

netconf@TEST-EX2200> show interfaces descriptions ge-0/0/10 

{master:0}

В этом нет ничего удивительного, если мы посмотрим на сообщение, которое было выведено после вызова метода send_config_set. Там нам сразу напоминают, что The configuration has been changed but not committed. То есть изменения то мы в базу конфигурации внесли, но не выполнили commit и изменения не применились. Поэтому, при работе с juniper мы должны в метод send_config_set передать не только список команд, но и параметр exit_config_mode = False, чтобы автоматически не выходить из режима конфигурации и можно было бы применить изменения. Дополнительно можно через вызов команды show | compare посмотреть, какие же изменения мы собираемся применить. Вот работающий код

    cmd = 'set interfaces ge-0/0/10 description "netmiko test"'
    dev.send_config_set(cmd, exit_config_mode=False)
    print(dev.send_command('show | compare'))
    print(dev.commit())

На выходе получим

[edit interfaces ge-0/0/10]
+   description "netmiko test";

commit
configuration check succeeds
commit complete

{master:0}[edit]
netconf@TEST-EX2200#

Первая часть вывода показывает, какие изменения мы будем применять, вторая - результат выполнения команды commit. Сообщение commit complete показывает, что изменения успешно применились.

В принципе, тут можно было бы и закончить с данной библиотекой, но хотелось бы чуть глубже копнуть особенности JunOS. Ранее я писал, а потом показывал на примере, что в JunOS (впрочем, как и у многих других вендоров) изменения в конфигурации не вступают в силу сразу, а требуют явного указания на их применение через команду commit. То есть в процессе конфигурирования можно даже удалить интерфейс, через который вы в данный момент подключены, если это повышает удобство конфигурирования (например, для выполнения команды copy interface). Пока вы не применили изменения, можно не волноваться. Но есть нюанс. Когда вы заходите в режим конфигурирования с помощью команды configure и начинаете вносить изменения, вы правите общую конфигурационную базу, а значит все изменения, которые вы внесли, доступны и для других пользователей.

Например, кто-то из консоли решил поправить интерфейс ge-0/0/11 и ввел команду set interfaces ge-0/0/11 description TEST1. После чего на некоторое время задумался, что делать дальше. А в этот момент вы запускаете написанный ранее скрипт по изменению интерфейса ge-0/0/10. В выводе вы можете с удивление увидеть, что почему-то show | compare показывает, что вы меняете два интерфейса вместо одного.

[edit interfaces ge-0/0/10]
+   description "netmiko test";
[edit interfaces ge-0/0/11]
+   description TEST1;

Хорошо, что команды тут безобидные, а если бы кто-то действительно удалил интерфейс, на котором наше устройство подключено - седых волос бы сильно прибавилось у многих.
Чтобы избежать подобных неприятных моментов у JunOS есть специальные режимы конфигурирования - exclusive и private (есть еще, но это основные, на мой взгляд). В режиме exclusive никто, кроме нас, не может изменять базу настроек. Для использования данного режима изменим наш скрипт, добавив в вызов send_config_set параметр config_mode_command='configure exclusive'

    cmd = 'set interfaces ge-0/0/10 description "netmiko test"'
    dev.send_config_set(cmd, config_mode_command='configure exclusive', exit_config_mode=False)
    print(dev.send_command('show | compare'))
    print(dev.commit())

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

    cmd = 'set interfaces ge-0/0/10 description "netmiko test"'
    dev.send_config_set(cmd, config_mode_command='configure private', exit_config_mode=False)
    print(dev.send_command('show | compare'))
    print(dev.commit())

Для обоих режимов работы, в отличие от просто configure, все изменения будут сброшены после выхода. Кроме того, если кто-то параллельно уже внес какие-то изменения в общую базу (в режиме configure), то, что configure private, что configure exclusive, не смогут войти в режим конфигурирования, выдав в консоли error: shared configuration database modified, а в случае netmiko - выдав по таймауту исключение Pattern not detected: '(?s:Entering configuration mode.*\\].*#)' in output
Еще одна интересная возможность JunOS - автоматический откат последних изменений. Если вы опасаетесь незапланированных последствий изменений, можете использовать commit confirmed. Данная команда применяет изменения и ожидает заданное количество времени (задается в минутах). Если в течение этого времени не совершен повторный commit, изменения откатываются до предыдущего состояния. Netmiko так же поддерживает данную команду. Просто добавьте параметры в вызов dev.commit(confirm = True, confirm_delay = 5) и через 5 минут, если вы повторно не вызовете commit(), все изменения будут отменены.

PyEZ

В связи с огромной популярностью Python в части автоматизации работы с сетевым оборудованием, многие компании выпускают библиотеки для работы с их оборудованием. Не исключение и Juniper, который выпустил библиотеку PyEZ. Данная библиотека работает по протоколу NETCONF, который надо не забыть включить перед использованием. Для этого выполним команду
set system services netconf ssh port 830
Перед использованием библиотеку необходимо установить
pip install junos-eznc

Подключение

Ниже - простейший код подключения к коммутатору и вывод информации о нем.

from jnpr.junos import Device
from pprint import pprint
dev = Device(host='192.168.0.151', user='netconf', password='NetConf')
dev.open()
pprint(dev.facts)

В результате будет выведена базовая информация об устройстве

{'2RE': False,
 'HOME': '/var/home/netconf',
 'RE0': {'last_reboot_reason': 'Router rebooted after a normal shutdown.',
         'mastership_state': 'master',
         'model': 'EX2200-C-12T-2G',
         'status': 'Absent',
         'up_time': '6 days, 7 hours, 15 minutes, 45 seconds'},
 'RE1': None,
 'RE_hw_mi': False,
 'current_re': ['master',
                'node',
                'fwdd',
                'member',
                'pfem',
                'fpc0',
                'feb0',
                'fpc16'],
 'domain': None,
 'fqdn': 'TEST-EX2200',
 'hostname': 'TEST-EX2200',
 'hostname_info': {'fpc0': 'TEST-EX2200'},
 'ifd_style': 'SWITCH',
 'junos_info': {'fpc0': {'object': junos.version_info(major=(15, 1), type=R, minor=5, build=5),
                         'text': '15.1R5.5'}},
 'master': 'RE0',
 'model': 'EX2200-C-12T-2G',
 'model_info': {'fpc0': 'EX2200-C-12T-2G'},
 'personality': 'SWITCH',
 're_info': {'default': {'0': {'last_reboot_reason': 'Router rebooted after a '
                                                     'normal shutdown.',
                               'mastership_state': 'master',
                               'model': 'EX2200-C-12T-2G',
                               'status': 'Absent'},
                         'default': {'last_reboot_reason': 'Router rebooted '
                                                           'after a normal '
                                                           'shutdown.',
                                     'mastership_state': 'master',
                                     'model': 'EX2200-C-12T-2G',
                                     'status': 'Absent'}}},
 're_master': {'default': '0'},
 'serialnumber': 'GP0…35',
 'srx_cluster': None,
 'srx_cluster_id': None,
 'srx_cluster_redundancy_group': None,
 'switch_style': 'VLAN',
 'vc_capable': True,
 'vc_fabric': False,
 'vc_master': '0',
 'vc_mode': 'Enabled',
 'version': '15.1R5.5',
 'version_RE0': None,
 'version_RE1': None,
 'version_info': junos.version_info(major=(15, 1), type=R, minor=5, build=5),
 'virtual': False}

Основной класс для подключения - Device. При создании туда передается вся нужная информация, такая как адрес устройства, логин и пароль или ключ для входа. Кроме того, можно передавать дополнительные параметры, например gather_facts=False, если нам не нужно собирать информацию об устройстве при подключении.

Сбор информации

После того, как мы подключились к устройству можно начинать работать с ним. Метод работы "в лоб" - вызов метода dev.cli() с передачей в него команды, которую необходимо выполнить. Однако, данный метод не является рекомендованным, о чем нам будет сообщено в возвращаемом значении. Например, при выполнении print(dev.cli('show version')) мы получим следующее:

RuntimeWarning:
CLI command is for debug use only!
Instead of:
cli('show version')
Use:
rpc.get_software_information()
  warnings.warn(warning_string, RuntimeWarning)
fpc0:
--------------------------------------------------------------------------
Hostname: TEST-EX2200
Model: ex2200-c-12t-2g
Junos: 15.1R5.5
JUNOS EX  Software Suite [15.1R5.5]
JUNOS FIPS mode utilities [15.1R5.5]
JUNOS Online Documentation [15.1R5.5]
JUNOS EX 2200 Software Suite [15.1R5.5]
JUNOS Web Management Platform Package [15.1R5.5]

Библиотека сама подсказывает, что основной метод работы с устройством - вызов rpc процедур. Узнать имя процедуры и название параметров можно двумя способами:
вызвать команду прямо на коробке, указав | display xml rpc

netconf@TEST-EX2200> show interfaces diagnostics optics ge-0/1/0 | display xml rpc  
<rpc-reply xmlns:junos="http://xml.juniper.net/junos/15.1R5/junos">
    <rpc>
        <get-interface-optics-diagnostics-information>
                <interface-name>ge-0/1/0</interface-name>
        </get-interface-optics-diagnostics-information>
    </rpc>
    <cli>
        <banner>{master:0}</banner>
    </cli>
</rpc-reply>

вызвать функцию cli_to_rpc_string которая и вернет правильное название функции и параметров

print(dev.cli_to_rpc_string('show interfaces diagnostics optics ge-0/1/0'))

Вывод
rpc.get_interface_optics_diagnostics_information(interface_name='ge-0/1/0')

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

from lxml import etree
response = dev.rpc.get_interface_optics_diagnostics_information(interface_name='ge-0/1/0')
etree.dump(response)

Вывод
<interface-information style="normal">
  <physical-interface>
    <name>
ge-0/1/0
</name>
    <optics-diagnostics>
      <laser-bias-current>
17.632
</laser-bias-current>
      <laser-output-power>
0.2850
</laser-output-power>
      <laser-output-power-dbm>
-5.45
</laser-output-power-dbm>
      <module-temperature celsius="50.9">
51 degrees C / 124 degrees F
</module-temperature>
      <module-voltage>
3.2670
</module-voltage>
      <rx-signal-avg-optical-power>
0.1936
</rx-signal-avg-optical-power>
      <rx-signal-avg-optical-power-dbm>
-7.13
</rx-signal-avg-optical-power-dbm>
      <laser-bias-current-high-alarm>
off
</laser-bias-current-high-alarm>
      <laser-bias-current-low-alarm>
off
</laser-bias-current-low-alarm>
      <laser-bias-current-high-warn>
off
</laser-bias-current-high-warn>
      <laser-bias-current-low-warn>
off
</laser-bias-current-low-warn>
      <laser-tx-power-high-alarm>
off
</laser-tx-power-high-alarm>
      <laser-tx-power-low-alarm>
off
</laser-tx-power-low-alarm>
      <laser-tx-power-high-warn>
off
</laser-tx-power-high-warn>
      <laser-tx-power-low-warn>
off
</laser-tx-power-low-warn>
      <module-temperature-high-alarm>
off
</module-temperature-high-alarm>
      <module-temperature-low-alarm>
off
</module-temperature-low-alarm>
      <module-temperature-high-warn>
off
</module-temperature-high-warn>
      <module-temperature-low-warn>
off
</module-temperature-low-warn>
      <module-voltage-high-alarm>
off
</module-voltage-high-alarm>
      <module-voltage-low-alarm>
off
</module-voltage-low-alarm>
      <module-voltage-high-warn>
off
</module-voltage-high-warn>
      <module-voltage-low-warn>
off
</module-voltage-low-warn>
      <laser-rx-power-high-alarm>
off
</laser-rx-power-high-alarm>
      <laser-rx-power-low-alarm>
off
</laser-rx-power-low-alarm>
      <laser-rx-power-high-warn>
off
</laser-rx-power-high-warn>
      <laser-rx-power-low-warn>
off
</laser-rx-power-low-warn>
      <laser-bias-current-high-alarm-threshold>
80.000
</laser-bias-current-high-alarm-threshold>
      <laser-bias-current-low-alarm-threshold>
2.000
</laser-bias-current-low-alarm-threshold>
      <laser-bias-current-high-warn-threshold>
70.000
</laser-bias-current-high-warn-threshold>
      <laser-bias-current-low-warn-threshold>
3.000
</laser-bias-current-low-warn-threshold>
      <laser-tx-power-high-alarm-threshold>
0.7940
</laser-tx-power-high-alarm-threshold>
      <laser-tx-power-high-alarm-threshold-dbm>
-1.00
</laser-tx-power-high-alarm-threshold-dbm>
      <laser-tx-power-low-alarm-threshold>
0.1000
</laser-tx-power-low-alarm-threshold>
      <laser-tx-power-low-alarm-threshold-dbm>
-10.00
</laser-tx-power-low-alarm-threshold-dbm>
      <laser-tx-power-high-warn-threshold>
0.6310
</laser-tx-power-high-warn-threshold>
      <laser-tx-power-high-warn-threshold-dbm>
-2.00
</laser-tx-power-high-warn-threshold-dbm>
      <laser-tx-power-low-warn-threshold>
0.1250
</laser-tx-power-low-warn-threshold>
      <laser-tx-power-low-warn-threshold-dbm>
-9.03
</laser-tx-power-low-warn-threshold-dbm>
      <module-temperature-high-alarm-threshold celsius="110.0">
110 degrees C / 230 degrees F
</module-temperature-high-alarm-threshold>
      <module-temperature-low-alarm-threshold celsius="-45.0">
-45 degrees C / -49 degrees F
</module-temperature-low-alarm-threshold>
      <module-temperature-high-warn-threshold celsius="95.0">
95 degrees C / 203 degrees F
</module-temperature-high-warn-threshold>
      <module-temperature-low-warn-threshold celsius="-42.0">
-42 degrees C / -44 degrees F
</module-temperature-low-warn-threshold>
      <module-voltage-high-alarm-threshold>
3.600
</module-voltage-high-alarm-threshold>
      <module-voltage-low-alarm-threshold>
3.000
</module-voltage-low-alarm-threshold>
      <module-voltage-high-warn-threshold>
3.500
</module-voltage-high-warn-threshold>
      <module-voltage-low-warn-threshold>
3.050
</module-voltage-low-warn-threshold>
      <laser-rx-power-high-alarm-threshold>
0.6310
</laser-rx-power-high-alarm-threshold>
      <laser-rx-power-high-alarm-threshold-dbm>
-2.00
</laser-rx-power-high-alarm-threshold-dbm>
      <laser-rx-power-low-alarm-threshold>
0.0050
</laser-rx-power-low-alarm-threshold>
      <laser-rx-power-low-alarm-threshold-dbm>
-23.01
</laser-rx-power-low-alarm-threshold-dbm>
      <laser-rx-power-high-warn-threshold>
0.5012
</laser-rx-power-high-warn-threshold>
      <laser-rx-power-high-warn-threshold-dbm>
-3.00
</laser-rx-power-high-warn-threshold-dbm>
      <laser-rx-power-low-warn-threshold>
0.0063
</laser-rx-power-low-warn-threshold>
      <laser-rx-power-low-warn-threshold-dbm>
-22.01
</laser-rx-power-low-warn-threshold-dbm>
    </optics-diagnostics>
  </physical-interface>
</interface-information>

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

print(response.findtext('physical-interface/optics-diagnostics/rx-signal-avg-optical-power-dbm'))

Вывод
-7.05

Примерно того же можно добиться, если включить фильтрацию при отправке запроса. Для этого надо передать параметр use_filter=True при создании подключения.

dev = Device(host='192.168.0.151', user='netconf', password='NetConf', gather_facts=False, use_filter=True)

dev.open()

# В фильтре указываем, что нам нужно получить только значение входящего сигнала и пороговое значение, при котором выдается предупреждение

filter = '<interface-information><physical-interface><optics-diagnostics><rx-signal-avg-optical-power-dbm/><laser-rx-power-low-warn-threshold-dbm/></optics-diagnostics></physical-interface></interface-information>'

response = dev.rpc.get_interface_optics_diagnostics_information(interface_name='ge-0/1/0', filter_xml=filter)
etree.dump(response)

Вывод
<interface-information style="normal">
  <physical-interface>
    <optics-diagnostics>
      <rx-signal-avg-optical-power-dbm>
-7.04
</rx-signal-avg-optical-power-dbm>
      <laser-rx-power-low-warn-threshold-dbm>
-22.01
</laser-rx-power-low-warn-threshold-dbm>
    </optics-diagnostics>
  </physical-interface>
</interface-information>

Чтобы закрыть тему с вызовом методов надо еще добавить, что информацию можно получать не только в формате xml, но и в json, и в виде текста (правда тут тоже будет xml, но один общий тег output внутри которого будет текстовый вывод команды). Для этого при вызове методе передать нужный формат

response = dev.rpc.get_interface_optics_diagnostics_information({'format' : 'text'}, interface_name='ge-0/1/0')
#print(response)
etree.dump(response)

Вывод (сокращенный)
<output>
Physical interface: ge-0/1/0
    Laser bias current                        :  17.744 mA
    Laser output power                        :  0.2850 mW / -5.45 dBm
    Module temperature                        :  53 degrees C / 127 degrees F
    Module voltage                            :  3.2670 V
    Receiver signal average optical power     :  0.2091 mW / -6.80 dBm
	.....
</output>

Небольшое замечание по поводу метода cli. Да, данный метод не рекомендуемый. Но иногда бывает, что другое решение найти трудно. К примеру, однажды мне надо было собирать информацию с резервного RE маршрутизатора. Обычно для этого заходишь на резервный RE командой request routing-engine login other-routing-engine и дальше смотришь, что нужно. Библиотека подсказала, что для этого есть метод request_login_to_other_routing_engine, который вроде как даже работал, но дальше новые вызовы выводили информацию все так же с основного RE. Сходу победить не получилось, поэтому пришлось решить задачу в лоб через cli.

Получение табличных данных

Но все же, чаще всего результатом запроса информации является некая таблица. Например, данных по интерфейсам или маршрутам. Основная полезность данной библиотеки - механизм получения таких данных. Можно, конечно, вызвать rpc.get_interface_information(media=True) и обрабатывать полученный вывод. Но есть метод проще.

from jnpr.junos.op.ethport import EthPortTable
ports = EthPortTable(dev)
ports.get()
pprint(ports.items())

Импортируем нужную нам таблицу и запрашиваем данные. Данные приходят уже в готовом для дальнейшей работы виде.

[('ge-0/0/0',
  [('oper', 'up'),
   ('admin', 'up'),
   ('description', None),
   ('mtu', 1514),
   ('link_mode', None),
   ('macaddr', '3c:8a:b0:91:ab:c3'),
   ('rx_bytes', '197766624115'),
   ('rx_packets', '294548059'),
   ('tx_bytes', '349962906124'),
   ('tx_packets', '341020897'),
   ('running', True),
   ('present', True)]),
 ('ge-0/0/1',
  [('oper', 'up'),
   ('admin', 'up'),
   ('description', None),
   ('mtu', 1514),
   ('link_mode', None),
   ('macaddr', '3c:8a:b0:91:ab:c4'),
   ('rx_bytes', '350181749861'),
   ('rx_packets', '342385318'),
   ('tx_bytes', '197376365008'),
   ('tx_packets', '292957432'),
   ('running', True),
...
   ('tx_bytes', '0'),
   ('tx_packets', '0'),
   ('running', True),
   ('present', True)])]

Вот пример простейшего вывода всех интерфейсов в виде таблицы

for intf, eth_stats in ports.items():
    eth_stats = dict(eth_stats)
    oper_state = eth_stats['oper']
    pkts_in = eth_stats['rx_packets']
    pkts_out = eth_stats['tx_packets']
    print("{:>15} {:>12} {:>12} {:>12}".format(intf, oper_state, pkts_in,
                                                pkts_out))

В составе библиотеки уже имеются готовые определения для наиболее востребованных запросов. Вот тут содержится их полный перечень.
Но это еще не все. Поскольку подготовленных определений много, но охватывают они все же далеко не все, мы можем самостоятельно определять нужные для нас таблицы и получать данные. Для примера, создадим таблицу для получения данных с оптических модулей. Описание таблиц производится с использованием YAML синтаксиса.
Вот часть вывода команды show interfaces diagnostics optics | display xml, которая нам будет нужна для создания yml файла

<rpc-reply xmlns:junos="http://xml.juniper.net/junos/15.1R5/junos">
    <interface-information xmlns="http://xml.juniper.net/junos/15.1R5/junos-interface" junos:style="normal">
        <physical-interface>
            <name>ge-0/1/0</name>
            <optics-diagnostics>
                <laser-bias-current>18.064</laser-bias-current>
                <laser-output-power>0.2850</laser-output-power>
                <laser-output-power-dbm>-5.45</laser-output-power-dbm>
                <module-temperature junos:celsius="54.8">55 degrees C / 131 degrees F
                </module-temperature>
                <module-voltage>3.2650</module-voltage>
                <rx-signal-avg-optical-power>0.2061</rx-signal-avg-optical-power>
                <rx-signal-avg-optical-power-dbm>-6.86</rx-signal-avg-optical-power-dbm>
...
            </optics-diagnostics>
        </physical-interface>
        <physical-interface>
            <name>ge-0/1/1</name>
            <optics-diagnostics>
                <optic-diagnostics-not-available>N/A</optic-diagnostics-not-available>
            </optics-diagnostics>
        </physical-interface>
    </interface-information>
    <cli>
        <banner>{master:0}</banner>
    </cli>
</rpc-reply>

Создадим файл optic.yml с определением того, как мы будем получать данные из нашего вывода ```

---
### --------------
### show interfaces diagnostics optics
### --------------
  
OpticTable:
  rpc: get-interface-optics-diagnostics-information
  key: name
  item: physical-interface
  view: OpticView
  
OpticView:
  fields:
    tx_pow: optics-diagnostics/laser-output-power-dbm
    rx_pow: optics-diagnostics/rx-signal-avg-optical-power-dbm
    temp: {optics-diagnostics/module-temperature/@celsius: float}

В файле мы описываем две сущности Table и View. Если попробовать объяснить по-простому, то Table описывает откуда мы вообще берем данные, какая именно часть этих данных будет использоваться для наполнения таблицы и что будет ключом. А View показывает, как мы будем из исходных данных формировать элементы таблицы. Возможно объяснение немного путанное, но, надеюсь, при разборе конкретного примера станет понятнее.
Итак, начала мы описываем таблицу - OpticTable
rpc - указываем имя процедуры, которая и является главным источником данных для заполнения таблицы. Имя этой процедуры мы можем получить разными способами, например вызвав команду и указав display xml rpc

netconf@TEST-EX2200> show interfaces diagnostics optics | display xml rpc 
<rpc-reply xmlns:junos="http://xml.juniper.net/junos/15.1R5/junos">
    <rpc>
        <get-interface-optics-diagnostics-information>
        </get-interface-optics-diagnostics-information>
    </rpc>
    <cli>
        <banner>{master:0}</banner>
    </cli>
</rpc-reply>

item - название элемента, из которого мы будем формировать строки нашей таблицы. В нашем случае данные для строки нашей таблицы содержатся в элементе <physical-interface>. Поскольку этот элемент находится прямо внутри основного элемента ответа <interface-information>, то мы указываем только его имя. Но если элемент является вложенным, надо будет указать весь путь.
key - каждая строка нашей таблицы состоит из двух частей - ключа и данных. То, как будет сформирован ключ, указываем после ключевого слова key. Тут может быть как одно поле, так и несколько. В рассматриваемом случае это имя интерфейса, которое указано сразу в дочернем элементе нашего <physical-interface>
view - то, что из полученных в результате вызова процедуры данных будет использовано для заполнения строки таблицы, указывается в отдельной сущности View. Название этой сущности тут мы и укажем.

OpticView
Тут в списке fields мы просто указываем, какие элементы необходимо выбрать, чтобы заполнить таблицу. Обратим внимание, что необходимые нам данные содержатся не напрямую в элементе <physical-interface>, который был указан в качестве item при описании таблицы, поэтому необходимо указать полный пусть до данного элемента. Так же мы можем брать не только значение внутри элемента, но и из его свойств и привести его к указанному типу данных, как продемонстрировано в строке temp: {optics-diagnostics/module-temperature/@celsius: float}
Создав файл с описанием нужной нам таблицы, мы можем загрузить его встроенными средствами библиотеки и создать нужные нами классы:

from jnpr.junos.factory import loadyaml
optic_class = loadyaml('optic.yml')
globals().update(optic_class)

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

optic_table = OpticTable(dev)
optic_table.get()
pprint(optic_table.items())

В результате получим требуемую информацию:

[('ge-0/1/0', [('tx_pow', '-5.45'), ('rx_pow', '-6.78'), ('temp', 54.9)]),
 ('ge-0/1/1', [('tx_pow', None), ('rx_pow', None), ('temp', None)])]

Конфигурирование

Для настройки нашего устройства в библиотеке PyEZ имеется специальный класс Config

from jnpr.junos.utils.config import Config
cfg = Config(dev)

Далее мы можем приступать к внесению изменений в конфигурацию устройства. Здесь работает стандартная схема Juniper: сначала изменения вносятся в базу конфигурации, но не применяются, для применения необходимо вызвать commit.
Библиотека позволяет подходить к процессу очень гибко. Так, новые настройки можно вносить в 3 форматах:

  • txt или conf - стандартный формат на базе фигурных скобок, который мы видим в консоли оборудования, если вывести show configuration

  • xml - настройки в формате xml, пример такой конфигурации можно посмотреть с помощью show configuration | display xml

  • set - форматирование в формате set. Фактически стандартный способ конфигурирования через консоль. Пример можно посмотреть с помощью show configuration | display set Изменения можно вносить как напрямую передав строку с настройками, так и загрузив данные из файла. Для примера создадим 3 файла в разных форматах и поменяем description на 3 разных интерфейсах config.txt

interfaces {
    ge-0/0/3 {
        description "Test format TXT";
    }
}

config.xml

<interfaces>
    <interface>
        <name>ge-0/0/4</name>
        <description>Test format XML</description>
    </interface>
</interfaces>

config.set

set interfaces ge-0/0/5 description "Test format SET"

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

cfg.load(path='config.txt')
cfg.load(path='config.xml')
cfg.load(path='config.set')
cfg.load('set interfaces ge-0/0/6 description "Test without file"', format='set')

print(cfg.diff())

В последней строке мы с помощью функции diff выводим все отличия новой конфигурации от текущей. В результате вывод будет такой:

[edit interfaces ge-0/0/3]
+   description "Test format TXT";
[edit interfaces ge-0/0/4]
+   description "Test format XML";
[edit interfaces ge-0/0/5]
+   description "Test format SET";
[edit interfaces ge-0/0/6]
+   description "Test without file";

Обратим внимание на два момента. Мы пока не применили наши изменения и рабочая конфигурация осталась такой же, что и была. Но если мы закомментируем в нашем скрипте все строки с cfg.load, оставив только вызов diff, то, при повторном запуске, вывод будет точно таким же, как и выше. То есть, несмотря на то, что наш скрипт завершился, мы меняли общую базу конфигурации и изменения там так и остались. Чтобы начать вносить изменения заново, нужно откатить уже внесенные изменения. Это можно сделать через rollback()

cfg.rollback()
cfg.load(path='config.txt')
print(cfg.diff())

Мы откатили изменения к исходному состоянию, загрузили только одно изменение и вывели разницу. Результат, как и ожидалось

[edit interfaces ge-0/0/3]
+   description "Test format TXT";

Как я и писал ранее, в части про Netmiko, править общую базу бывает весьма опасно и есть два альтернативных подхода: режим private, где мы работаем с собственной копией базы конфигурации, и режим exclusive, когда мы блокируем общую базу и правим ее единолично.
Для примера работы в режиме private запустим следующий код

print(1)
with Config(dev) as cfg:
    cfg.load(path='config.txt')
    print(cfg.diff())

print(2)
with Config(dev) as cfg:
    print(cfg.diff())

Мы дважды входим в режим конфигурирования и выводим разницу между рабочей конфигураций и новой версией
Ожидаемо получим

1

[edit interfaces ge-0/0/3]
+   description "Test format TXT";

2

[edit interfaces ge-0/0/3]
+   description "Test format TXT";

После выхода из режима конфигурирования и последующего входа изменения, внесенные в общую базу, остались. Теперь добавим mode='private'

print(1)
with Config(dev, mode='private') as cfg:
    cfg.load(path='config.txt')
    print(cfg.diff())

print(2)
with Config(dev, mode='private') as cfg:
    print(cfg.diff())

При первом запуске нас поджидает разочарование в виде вылетевшего исключения:

jnpr.junos.exception.RpcError: RpcError(severity: error, bad_element: None, message: shared configuration database modified)

Изменения в общей базе остались и зайти в режим private не получается. Откатим изменения вручную и повторно выполним код.

1

[edit interfaces ge-0/0/3]
+   description "Test format TXT";

2
None

Тут тоже все, как и ожидалось. После выхода из режима private все изменения обнулились.
С режимом exlusive тоже все просто. Вот код

with Config(dev) as cfg:
    print(1)
    cfg.lock()
    cfg.load(path='config.txt')
    print(cfg.diff())
    print(2)
    cfg.unlock()
    print(cfg.diff())

Вывод

1

[edit interfaces ge-0/0/3]
+   description "Test format TXT";

2
None

Мы блокируем базу конфигурации с помощью lock(), вносим изменения. Но как только мы делаем unlock(), все изменения обнуляются.

Еще одна приятная возможность загрузки файлов конфигураций - использование их как шаблонов, которые могут формироваться динамически. Для их формирования можно использовать синтаксис Jinja2. Для примера переделаем файл xml для правки сразу нескольких интерфейсов
config.xml

<interfaces>
{% for key, value in interface_descr.items() %}
    <interface>
        <name>{{ key }}</name>
        <description>{{ value }}</description>
    </interface>
{% endfor %}
</interfaces>

А сам скрипт

interface_descr = {
     'ge-0/0/6' : 'interface 6',
     'ge-0/0/7' : 'interface 7',
     'ge-0/0/8' : 'interface 8',
     'ge-0/0/9' : 'interface 9'}

with Config(dev) as cfg:
    cfg.lock()
    cfg.load(template_path='config.xml', template_vars= { 'interface_descr': interface_descr })
    print(cfg.diff())

В методе load() мы вместо параметра path указываем template_path, кроме того, в template_vars мы передаем все переменные, которые будут использоваться при обработке шаблона. В результате вывод будет следующий

[edit interfaces ge-0/0/6]
+   description "interface 6";
[edit interfaces ge-0/0/7]
+   description "interface 7";
[edit interfaces ge-0/0/8]
+   description "interface 8";
[edit interfaces ge-0/0/9]
+   description "interface 9";

После того, как мы загрузили новые изменения в конфигурацию, нам остается сделать две вещи:

  1. Проверить, что изменения применимы (в консоли это была бы команда commit check)

  2. Применить изменения Первое мы выполняем при помощи метода commit_check, который возвращает True, если изменения могут быть применены, либо вызывает исключение, из которого можно получить информацию, что же именно в новом конфиге не понравилось. Ну и применение изменений производится при помощи метода commit(). Данный метод может принимать следующие параметры:

Параметр

Описание

comment

Текстовое описание коммита.

confirm

Возможность автоматического отката изменений. Указывается число минут до отката

sync

Булевское значение. Если True будет выполнена команда commit synchronize, которая синхронизирует изменения на обоих RE в конфигурациях, где есть два RE

detail

Булевское значение. Если True, the commit() выведет дополнительную информацию по выполнению. Используется для отладки

force_sync

Булевское значение. Если True, выполняет commit synchronize force.

full

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

Проверим на практике. Выведем описанием интерфейсов до изменений, загрузим и применим изменения и повторно посмотрим описания.

resp = dev.rpc.get_interface_information(descriptions=True)
for if_data in resp.findall('physical-interface'):
    if_name = if_data.findtext('name').strip()
    if_descr = if_data.findtext('description').strip()
    print(f'{if_name}\t{if_descr}')

print('Load configuration')
interface_descr = {
     'ge-0/0/6' : 'interface 6',
     'ge-0/0/7' : 'interface 7',
     'ge-0/0/8' : 'interface 8',
     'ge-0/0/9' : 'interface 9'}

with Config(dev) as cfg:
    cfg.lock()
    cfg.load(template_path='config.xml', template_vars= { 'interface_descr': interface_descr })
    print(cfg.diff())
    cfg.commit()

print('After commit')
resp = dev.rpc.get_interface_information(descriptions=True)
for if_data in resp.findall('physical-interface'):
    if_name = if_data.findtext('name').strip()
    if_descr = if_data.findtext('description').strip()
    print(f'{if_name}\t{if_descr}')

Результат работы

ge-0/0/2        FREE
Load configuration

[edit interfaces ge-0/0/6]
+   description "interface 6";
[edit interfaces ge-0/0/7]
+   description "interface 7";
[edit interfaces ge-0/0/8]
+   description "interface 8";
[edit interfaces ge-0/0/9]
+   description "interface 9";

After commit
ge-0/0/2        FREE
ge-0/0/6        interface 6
ge-0/0/7        interface 7
ge-0/0/8        interface 8
ge-0/0/9        interface 9

Заключение

На этом я бы хотел завершить данную статью. Конечно, показанные тут подходы не единственные возможные для автоматизации Juniper, есть еще множество других возможностей, от простого вызова команды ssh и работы с вводом/выводом до использования еще более высокоуровневых библиотек, вроде Napalm, и систем управления конфигурациями, вроде Ansible. Если будет интерес, про это можно написать отдельные статьи. Буду рад комментариям и с удовольствием отвечу на вопросы читателей.

Комментарии (0)