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

TextFSM Primer

TextFSM был создан Google для преобразования полуструктурированных данных с сетевых устройств в структурированные данные, к которым можно легко получить программный доступ. TextFSM — это язык, специфичный для домена (DSL), использующий под капотом RegEx для анализа данных. Это означает, что требуются некоторые знания RegEx

Давайте взглянем на шаблон TextFSM, а затем разберем его, чтобы лучше понять, как работает TextFSM. Ниже представлен шаблон из NTC Templates, который мы будем использовать для этого поста.

Value Required NEIGHBOR (\S{0,20})
Value Required LOCAL_INTERFACE (\S+)
Value CAPABILITIES (\S*)
Value Required NEIGHBOR_INTERFACE (\S+)

Start
  ^Device.*ID -> LLDP
  # Capture time-stamp if vty line has command time-stamping turned on
  ^Load\s+for\s+
  ^Time\s+source\s+is

LLDP
  ^${NEIGHBOR}\s*${LOCAL_INTERFACE}\s+\d+\s+${CAPABILITIES}\s+${NEIGHBOR_INTERFACE} -> Record
  ^${NEIGHBOR}
  ^\s+${LOCAL_INTERFACE}\s+\d+\s+${CAPABILITIES}\s+${NEIGHBOR_INTERFACE} -> Record

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

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
S2                  Fa0/13         120        B               Gi0/13
Cisco-switch-1      Gi1/0/7        120                        Gi0/1
Juniper-switch1     Gi2/0/1        120        B,R             666
Juniper-switch1     Gi1/0/1        120        B,R             531

Total entries displayed: 4

Вот результат, возвращенный после анализа полуструктурированных данных с использованием шаблонов NTC.

---
parsed_sample:
  - capabilities: "B"
    local_interface: "Fa0/13"
    neighbor: "S2"
    neighbor_interface: "Gi0/13"
  - capabilities: ""
    local_interface: "Gi1/0/7"
    neighbor: "Cisco-switch-1"
    neighbor_interface: "Gi0/1"
  - capabilities: "B,R"
    local_interface: "Gi2/0/1"
    neighbor: "Juniper-switch1"
    neighbor_interface: "666"
  - capabilities: "B,R"
    local_interface: "Gi1/0/1"
    neighbor: "Juniper-switch1"
    neighbor_interface: "531"

Значения

Как видно из полученных нами выходных данных, Valueв шаблоне используется как ключ (столбец) в каждом возвращаемом словаре (строке).

Есть несколько ключевых слов, которые могут изменить способ обработки значения, например следующие:

  • Обязательно: запись (строка) сохраняется в таблице только в том случае, если это значение совпадает.
  • Заполнение: ранее сопоставленное значение сохраняется для последующих записей (если оно явно не очищено или не сопоставлено снова). Другими словами, последнее совпавшее значение копируется в новые строки, если оно не будет найдено снова.
  • Список: значение представляет собой список, добавляемый к каждому совпадению. Обычно совпадение перезаписывает любое предыдущее значение в этой строке.
  • Ключ: объявляет, что содержимое поля способствует уникальному идентификатору строки. Это можно использовать для связывания данных из нескольких шаблонов в единую структуру.
  • Заполнение: аналогично заполнению, но заполняется вверх, пока не будет найдена непустая запись. Несовместимо с Required.

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

\S+ соответствует любому непробельному символу, который требует, чтобы данные были либо контролируемыми, либо хорошо известными, как указано выше.

Напомним, что мы только что обсуждали, здесь пробой Значение строки:.Value {KEYWORD} {VALUE_NAME} (RegEx)

Состояния

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

Государственные правила

Правила определяют строки, которые мы хотим захватить, с определениями значений в начале шаблона. Каждая линия правила должна начинаться с символа карата ( ^). Правила необязательно должны заканчиваться ->действием правила, но оно может потребоваться в зависимости от данных. ->Обозначает действие правила и говорит TextFSM, что делать с данными, полученными до этого момента. Вскоре мы обсудим действия правила. Значения обозначаются ${VALUE_NAME}в правилах состояния, которые будут расширены с помощью RegEx из определения значения.

Имейте в виду, что вся строка необязательно должна быть RegEx или values ​​( ${NEIGHBOR}), но также может быть обычным текстом для сопоставления. Незаметно TextFSM преобразует каждое правило в полную строку RegEx. Если мы посмотрим на первую строку под LLDPсостоянием, за кадром она будет выглядеть следующим образом:^(\S{0,20})\s*(\S+)\s+\d+\s+(\S*)\s+(\S+)

Правило состояния необязательно должно соответствовать всей строке, которую мы видим в нашем шаблоне с расширением `. ^ Загрузить \ s + для \ s + , which will match any line that starts with Загрузить для `.

Правило и линейные действия

Действия правила могут применяться для каждой строки правила состояния, но они влияют на поведение, и размещение в пределах состояний должно быть тщательно продумано. Есть строковые действия, которые сообщают TextFSM, что нужно делать с текущей строкой во время ее обработки, а затем действия правил сообщают TextFSM, что делать с захваченными значениями. Согласно действию по умолчанию, любая строка, не содержащая символа ->, равна Next.NoRecord. Чтобы лучше понять это, давайте рассмотрим, какие варианты у нас есть, когда дело доходит до использования линии и действий правил, указав ->.

Действия линии

  • Далее (по умолчанию) : закончить строку ввода, прочитать следующую строку и снова начать сопоставление с начала состояния. Это поведение по умолчанию, если действие строки не указано.
  • Продолжить: сохранить текущую строку и не возобновлять сопоставление с первого правила состояния. Продолжить обработку правил, как если бы совпадение не произошло (присвоение значений все еще происходит).

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

Вот пример шаблона:

Value List INTERFACES ([\w\./]+)

..omitted for brevity

VLANS
  ^\d+ -> Continue.Record
  ^${VLAN_ID}\s+${NAME}\s+${STATUS}\s*$$
  ^${VLAN_ID}\s+${NAME}\s+${STATUS}\s+${INTERFACES},* -> Continue
  ^\d+\s+(?:\S+\s+){3}${INTERFACES},* -> Continue
  ^\d+\s+(?:\S+\s+){4}${INTERFACES},* -> Continue
  ^\d+\s+(?:\S+\s+){5}${INTERFACES},* -> Continue
  ^\d+\s+(?:\S+\s+){6}${INTERFACES},* -> Continue
  ^\d+\s+(?:\S+\s+){7}${INTERFACES},* -> Continue

Вот пример частично структурированных данных, которые будут проанализированы:

50   VLan50                           active    Fa0/1, Fa0/2, Fa0/3, Fa0/4, Fa0/5, Fa0/6, Fa0/7, Fa0/8, Fa0/9
                                                Fa0/10, Fa0/11, Fa0/12

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

Это означает, что наши структурированные данные будут выглядеть следующим образом:

---
parsed_sample:
  - vlan_id: "50"
    name: "VLan50"
    status: "active"
    interfaces:
      - "Fa0/1"
      - "Fa0/2"
      - "Fa0/3"
      - "Fa0/4"
      - "Fa0/5"
      - "Fa0/6"
      - "Fa0/7"
      - "Fa0/8"
      - "Fa0/9"
      - "Fa0/10"
      - "Fa0/11"
      - "Fa0/12"

Правило Действия

  • NoRecord (по умолчанию) : ничего не делать. Это поведение по умолчанию, если действие записи не указано.
  • Запись: Запишите собранные значения до строки в возвращаемых данных. Значения без заполнения очищаются. Примечание. Никакая запись не выводится, если есть неназначенные «Обязательные» значения.
  • Очистить: очистить значения без заполнения.
  • Clearall : очистить все значения.
  • Состояние: переход в другое состояние.
  • Ошибка: это встроенное состояние, которое отбрасывает все захваченные значения и возвращает исключение.

Мы используем действие правила ошибки, чтобы помочь устранить неполадки в наших шаблонах и убедиться, что в наших шаблонах учитываются правильные данные. Вот как мы его используем: `^. -> Ошибка`, которая выдаст исключение для строки, не соответствующей какому-либо определенному правилу состояния.

`-> Continue.State` не может предотвращать зацикливание в TextFSM.

Если мы посмотрим на шаблон под LLDPсостоянием, мы увидим в нем две -> Recordопции. Это позволяет нам захватывать соответствующие значения, но путем синтаксического анализа немного отличающегося вывода.

Мы также можем комбинировать линейное действие с действием правила. Синтаксис для этого LineAction.RuleAction.

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

Топология

Ниже приведено изображение топологии, которую мы будем использовать для проверки соседей LLDP в нашей лабораторной топологии. Это простая топология с тремя маршрутизаторами Cisco IOS, которые соединены вместе и имеют включенный LLDP.

Установка Ansible

К счастью, наша топология и последующая инвентаризация будут простыми. У нас есть маршрутизаторы в группе под названием, iosкоторая затем имеет соответствующий ios.ymlфайл в group_varsпапке, в которой есть переменные, относящиеся к подключению к этим устройствам, которые я вскоре покажу. Затем у нас есть {hostname}.ymlфайлы для каждого маршрутизатора, содержащие approved_neighborsпеременную, которую мы будем использовать для проверки соседей, которые мы видим из наших проанализированных LLDPданных. Ниже представлено дерево нашего каталога, которое содержит сборник пьес и инвентарь Ansible.

❯ tree
.
├── ansible.cfg
├── group_vars
│   └── ios.yml
├── host_vars
│   ├── iosv-0.yml
│   ├── iosv-1.yml
│   └── iosv-2.yml
├── inventory
└── pb.validate.neighbors.yml

2 directories, 7 files

Вот inventoryфайл, который связывает наши маршрутизаторы с iosгруппой.

[ios]
iosv-0 ansible_host=10.188.1.56
iosv-1 ansible_host=10.188.1.54
iosv-2 ansible_host=10.188.1.55

Вот содержимое ios.ymlфайла. Это указывает пользователя, которого мы хотим подключиться к маршрутизаторам, а также ansible_network_osчтобы сообщить Ansible, к какому типу сетевого устройства мы будем подключаться.

Если вы заметили, это отличается от синтаксиса Ansible <= 2.9 и использует путь к, cisco.ios.iosа не просто ios. Это связано с тем, что мы запускаем Ansible 2.10 и переходим на использование синтаксиса, который Ansible будет применять в будущем. Вы также заметите некоторые различия с использованием Ansible 2.10 в нашей инструкции.

---
ansible_user: "cisco"
ansible_network_os: "cisco.ios.ios"

Вот взгляд на host var, который мы определили.

---
approved_neighbors:
  - local_intf: "Gi0/0"
    neighbor: "iosv-1"
  - local_intf: "Gi0/1"

Теперь посмотрим на pb.validate.neighbors.yml.

---
- hosts: "ios"
  connection: "ansible.netcommon.network_cli"
  gather_facts: "no"

  tasks:
    - name: "PARSE LLDP INFO INTO STRUCTURED DATA"
      ansible.netcommon.cli_parse:
        command: "show lldp neighbors"
        parser:
          name: ansible.netcommon.ntc_templates
        set_fact: "lldp_neighbors"

    - name: "ASSERT THE CORRECT NEIGHBORS ARE SEEN"
      assert:
        that:
          - "lldp_neighbors | selectattr('local_interface', 'equalto', item['local_intf']) | map(attribute='neighbor') | first == item['neighbor']"
      loop: "{{ approved_neighbors }}"

Плейбук начинается с определения наших хостов как iosгруппы в нашем inventoryфайле, которая состоит из наших трех маршрутизаторов IOS. В методе подключения используется синтаксис> = Ansible 2.10, network_cliи мы отключили сбор фактов.

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

Первая задача использует ansible.netcommon.cli_parseмодуль для запуска команды на устройстве, а затем анализирует данные с помощью нашего определенного ansible.netcommon.ntc_templatesсинтаксического анализатора. Этот вывод сохраняется в lldp_neighborsсоответствии с set_factдирективой задачи.

Если вы хотите запустить тот же сценарий, убедитесь, что вы ntc-templatesустановили через pip install ntc-templates.

Следующий цикл задачи будут над нашим approved_neighborsпеременным , а затем попытаться найти соответствие в наших проанализированных данных путем поиска записи , которая имеет значение для ключа , local_interfaceкоторый соответствует тому , что мы установили для local_intfв approved_neighborsи что neighborключ также соответствует нашей neighborценности. Наша playbook потерпит неудачу, если какой-либо из соседей не соответствует тому, что мы определили approved_neighbors.

То, как развивается Ansible, и методологии, которые мы использовали в playbook, не ограничивают потенциал playbook одними только iosи мы фактически можем поменять iosопределение хостов для allлюбого количества групп и хостов, которые являются мультивендерами. Это связано с закулисной магией, которая ansible.netcommon.cli_parseвыполняется с ansible_network_osпеременной, которую мы установили в group vars. Он использует эту переменную, чтобы определить, какой nos_commandмодуль запустить для подключения к устройству и какой шаблон использовать для анализа возвращенных данных.

Выходные данные Playbook

Давайте продолжим, запустим playbook и посмотрим, какой результат мы получим.

❯ ansible-playbook -i inventory pb.validate.neighbors.yml -vv -k
ansible-playbook 2.10.2
  config file = /Users/myohman/local-dev/blog-posts/ansible.cfg
  configured module search path = ['/Users/myohman/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /Users/myohman/.virtualenvs/main-3.8/lib/python3.8/site-packages/ansible
  executable location = /Users/myohman/.virtualenvs/main-3.8/bin/ansible-playbook
  python version = 3.8.6 (default, Oct 16 2020, 21:27:09) [Clang 12.0.0 (clang-1200.0.32.2)]
Using /Users/myohman/local-dev/blog-posts/ansible.cfg as config file
SSH password:
redirecting (type: callback) ansible.builtin.yaml to community.general.yaml
redirecting (type: callback) ansible.builtin.yaml to community.general.yaml

PLAYBOOK: pb.validate.neighbors.yml ***********************************************************************************
1 plays in pb.validate.neighbors.yml

PLAY [ios] ************************************************************************************************************
META: ran handlers

TASK [Parse LLDP info into structured data] ***************************************************************************
task path: /Users/myohman/local-dev/blog-posts/pb.validate.neighbors.yml:10
ok: [iosv-0] => changed=false
  ansible_facts:
    lldp_neighbors:
    - capabilities: R
      local_interface: Gi0/1
      neighbor: iosv-2
      neighbor_interface: Gi0/0
    - capabilities: R
      local_interface: Gi0/0
      neighbor: iosv-1
      neighbor_interface: Gi0/0
  parsed:
  - capabilities: R
    local_interface: Gi0/1
    neighbor: iosv-2
    neighbor_interface: Gi0/0
  - capabilities: R
    local_interface: Gi0/0
    neighbor: iosv-1
    neighbor_interface: Gi0/0
  stdout: |-
    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
    iosv-2              Gi0/1          120        R               Gi0/0
    iosv-1              Gi0/0          120        R               Gi0/0

    Total entries displayed: 2
  stdout_lines: <omitted>
ok: [iosv-1] => changed=false
  ansible_facts:
    lldp_neighbors:
    - capabilities: R
      local_interface: Gi0/1
      neighbor: iosv-2
      neighbor_interface: Gi0/1
    - capabilities: R
      local_interface: Gi0/0
      neighbor: iosv-0
      neighbor_interface: Gi0/0
  parsed:
  - capabilities: R
    local_interface: Gi0/1
    neighbor: iosv-2
    neighbor_interface: Gi0/1
  - capabilities: R
    local_interface: Gi0/0
    neighbor: iosv-0
    neighbor_interface: Gi0/0
  stdout: |-
    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
    iosv-2              Gi0/1          120        R               Gi0/1
    iosv-0              Gi0/0          120        R               Gi0/0

    Total entries displayed: 2
  stdout_lines: <omitted>
ok: [iosv-2] => changed=false
  ansible_facts:
    lldp_neighbors:
    - capabilities: R
      local_interface: Gi0/1
      neighbor: iosv-1
      neighbor_interface: Gi0/1
    - capabilities: R
      local_interface: Gi0/0
      neighbor: iosv-0
      neighbor_interface: Gi0/1
  parsed:
  - capabilities: R
    local_interface: Gi0/1
    neighbor: iosv-1
    neighbor_interface: Gi0/1
  - capabilities: R
    local_interface: Gi0/0
    neighbor: iosv-0
    neighbor_interface: Gi0/1
  stdout: |-
    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
    iosv-1              Gi0/1          120        R               Gi0/1
    iosv-0              Gi0/0          120        R               Gi0/1

    Total entries displayed: 2
  stdout_lines: <omitted>

TASK [Assert the correct neighbors are seen] **************************************************************************
task path: /Users/myohman/local-dev/blog-posts/pb.validate.neighbors.yml:17
ok: [iosv-0] => (item={'local_intf': 'Gi0/0', 'neighbor': 'iosv-1'}) => changed=false
  ansible_loop_var: item
  item:
    local_intf: Gi0/0
    neighbor: iosv-1
  msg: All assertions passed
ok: [iosv-1] => (item={'local_intf': 'Gi0/0', 'neighbor': 'iosv-0'}) => changed=false
  ansible_loop_var: item
  item:
    local_intf: Gi0/0
    neighbor: iosv-0
  msg: All assertions passed
ok: [iosv-0] => (item={'local_intf': 'Gi0/1', 'neighbor': 'iosv-2'}) => changed=false
  ansible_loop_var: item
  item:
    local_intf: Gi0/1
    neighbor: iosv-2
  msg: All assertions passed
ok: [iosv-1] => (item={'local_intf': 'Gi0/1', 'neighbor': 'iosv-2'}) => changed=false
  ansible_loop_var: item
  item:
    local_intf: Gi0/1
    neighbor: iosv-2
  msg: All assertions passed
ok: [iosv-2] => (item={'local_intf': 'Gi0/0', 'neighbor': 'iosv-0'}) => changed=false
  ansible_loop_var: item
  item:
    local_intf: Gi0/0
    neighbor: iosv-0
  msg: All assertions passed
ok: [iosv-2] => (item={'local_intf': 'Gi0/1', 'neighbor': 'iosv-1'}) => changed=false
  ansible_loop_var: item
  item:
    local_intf: Gi0/1
    neighbor: iosv-1
  msg: All assertions passed
META: ran handlers
META: ran handlers

PLAY RECAP ************************************************************************************************************
iosv-0                     : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
iosv-1                     : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
iosv-2                     : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Если мы более внимательно посмотрим на вывод первой задачи, мы увидим под parsedключом, а также установив fact ( lldp_neighbors), что у нас есть структурированные данные, через которые проходит необработанный вывод NTC Templates.

Вторая задача показывает цикл для каждого хоста и то, itemчто он использует во время цикла. Если вы посмотрите на нашу книгу, мы используем local_intfи neighborдля наших утверждений из нашей approved_neighborsпеременной.

Резюме

Надеюсь, вам понравился этот пост в блоге, и вы немного больше понимаете о TextFSM, шаблонах NTC и о том, насколько легко их использовать с Ansible. Простота использования не является уникальной для Ansible, поскольку этого также можно легко достичь с помощью Netmiko или необработанного Python, но с использованием Ansible из-за промышленного принятия Ansible.