Parsing Strategies – Ansible Native Parser

Blog Detail

Thank you for joining me for Part 4 of the parsing strategies blog series. This post will dive deeper into using Ansible’s Native Parser and the parse_cli filter plug-in. Each method requires a different templates to be created. Both defined in YAML, but they provide different outputs.

Ansible seems to have changed course as I was going to cover the Ansible network engine role that has a built-in parser, but it’s been superseded by the above parsers.

Let’s dive right in and take a look at what the show lldp neighbors parsers will look like, how they work, and how we need to modify our existing playbook to use the Ansible native parsers.

Ansible Native Parser Primer

Unlike the past two blog posts that covered NTC Templates and Cisco’s PyATS Genie library, Ansible’s native parsers do not provide any built-in templates or a library of existing templates to use to parse data. This can be a downside, as you will need to create your own. But it doesn’t require any external dependencies, which could be difficult to install in some environments.

The link in the first paragraph provides some great insight and examples on the available options when using the native parser. Our example is straightforward and doesn’t require any complex logic. The recommended way to use the parser is for it to create a dictionary with each key being an identifier and then the other data that’s captured being nested key/value pairs inside of the identifier key. You could use it to extrapolate a dictionary without any nesting depending on the output. But if you’re looking to capture multiple entries, it’s best to follow the nested key method.

Our template is stored within the templates/ folder and is named to use the built-in method for finding the templates. You can find more info in the corresponding article above, but as a refresher, it looks for the following in the templates/ folder, _.yaml.

❯ tree
.
├── ansible.cfg
├── filter_plugins
│   └── custom_filters.py
├── group_vars
│   ├── all
│   │   └── all.yml
│   └── ios.yml
├── host_vars
│   ├── iosv-0.yml
│   ├── iosv-1.yml
│   └── iosv-2.yml
├── inventory
├── pb.validate.neighbors.yml
└── templates
    └── ios_show_lldp_neighbors.yaml

6 directories, 12 files

As a refresher, let’s look at output from one of our routers for show lldp neighbors.

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

Let’s take a look at the template.

---
- example: "iosv-2                  Gi0/1         120        R               Gi0/1"
  getval: '(?P<neighbor>\S+)\s+(?P<local_interface>\S+)\s+(?P<hold_time>\d+)\s+(?P<capabilities>\S+)?\s+(?P<neighbor_interface>\S+)'
  result:
    "{{ local_interface }}":
      neighbor: "{{ neighbor }}"
      capabilities: "{{ capabilities }}"
      hold_time: "{{ hold_time }}"
      neighbor_interface: "{{ neighbor_interface }}"

The neighbor keys, etc. are nested under ““. These templates can become complicated depending on your RegEx and the output. For example, if we were to change the hold_time to be \S+, this would capture the header in the output and capture incorrect data.

We can see that we provide an example of what the line we’re expecting to capture looks like. The getval is where we build out the RegEx using the named capture functionality. These named captures are then referenced within our result dictionary.

Our payload using the getval against the example would look like the following:

---
Fa0/13:
  neighbor: "S2"
  capabilities: "B"
  hold_time: 120
  neighbor_interface: "Gi0/13"

Due to the output being a dictionary, each additional entry will be a new top-level key with the structured data below. One thing to ensure, since this is a dictionary, is to select a top-level key that will be unique among all the output. If capabilities was used as the top-level key, there could be unintended consequences with data parsed being overwritten with newer data. For example, the output above would result in only one entry being returned.

Now that we understand how this parser works, we’ll move on to the parse_cli filter plug-in.

Ansible parse_cli Primer

Since there isn’t much flexibility for the data outputted from the Ansible native parser, we can create a parser that will prevent us from having to create a custom filter plug-in to manipulate the data to work with the assertion task. This template is also written in YAML.

Check out the parse_cli docs.

---
vars:
  lldp_neighbors:
    local_interface: "{{ item.local_interface }}"
    neighbor: "{{ item.neighbor }}"
    hold_time: "{{ item.hold_time }}"
    capabilities: "{{ item.capabilities }}"
    neighbor_interface: "{{ item.neighbor_interface }}"

keys:
  lldp_neighbors:
    value: "{{ lldp_neighbors }}"
    items: '^(?P<neighbor>\S+)\s+(?P<local_interface>\S+)\s+(?P<hold_time>\d+)\s+(?P<capabilities>\S+)?\s+(?P<neighbor_interface>\S+)'

The results of this template will provide the same output as we’d expect from the TextFSM template in part 2.

ok: [iosv-0] => changed=false 
  ansible_facts:
    parse_cli_lldp_neighbors:
      lldp_neighbors:
      - capabilities: R
        hold_time: 120
        local_interface: Gi0/0
        neighbor: iosv-1
        neighbor_interface: Gi0/0
      - capabilities: R
        hold_time: 120
        local_interface: Gi0/1
        neighbor: iosv-2
        neighbor_interface: Gi0/0

If we dissect the template a bit further, the top-level keys can be multiple key/value pairs, with the key(s) being returned by the filter. Under each key that is defined, must be value and items key/value pairs. The value will correlate to the key stored under the top-level vars key. This tells it how to structure the data once it has been parsed. The items value is the RegEx that is used to parse the unstructured data using the named capture that we covered above. This RegEx can be reused between both of these methods that we have covered in this blog post.

This method does provide additional ways to format the data, and we can actually update the template to have the same format as the Ansible native parser we discussed above.

---
vars:
  lldp_neighbors:
    key: "{{ item.local_interface }}"
    values:
      neighbor: "{{ item.neighbor }}"
      hold_time: "{{ item.hold_time }}"
      capabilities: "{{ item.capabilities }}"
      neighbor_interface: "{{ item.neighbor_interface }}"

keys:
  lldp_neighbors:
    value: "{{ lldp_neighbors }}"
    items: '^(?P<neighbor>\S+)\s+(?P<local_interface>\S+)\s+(?P<hold_time>\d+)\s+(?P<capabilities>\S+)?\s+(?P<neighbor_interface>\S+)'

And the output we receive:

ok: [iosv-1] => changed=false 
  ansible_facts:
    parse_cli_lldp_neighbors:
      lldp_neighbors:
        Gi0/0:
          capabilities: R
          hold_time: 120
          neighbor: iosv-0
          neighbor_interface: Gi0/0
        Gi0/1:
          capabilities: R
          hold_time: 120
          neighbor: iosv-2
          neighbor_interface: Gi0/1

.. warning:: Please remember that since this is a dictionary, select a top-level key that will unique among all the output. If capabilities was used as the top-level key, there could be unintended consequences with data parsed being overwritten with newer data. For example, the output above would result in only one entry being returned.

For more details, check out the documentation.

The Topology.. Again

Below is a picture of the lab topology we’re using to validate LLDP neighbors. It’s a simple topology with three Cisco IOS routers connected together and that have LLDP enabled.

Ansible Setup Updates

We’ve already covered most of the Ansible setup in Part 2, but we’ll explain the small changes we have to make to use the built-in Ansible parsers.

As a refresher, since there are no changes here, here is a look at a host var we’ve defined.

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

Now let’s take a look at the changes in pb.validate.neighbors.yml.

---
- hosts: "ios"
  connection: "ansible.netcommon.network_cli"
  gather_facts: "no"
  vars:
    command: "show lldp neighbors"

  tasks:
    - name: "PARSE LLDP INFO INTO STRUCTURED DATA"
      ansible.netcommon.cli_parse:
        command: "{{ command }}"
        parser:
          name: "ansible.netcommon.native"
        set_fact: "lldp_neighbors"
      register: "lldp_output"

    - name: "PARSE DATA AND SET_FACT WITH PARSE_CLI FILTER PLUGIN"
      ansible.builtin.set_fact:
        parse_cli_lldp_neighbors: "{{ lldp_output['stdout'] | ansible.netcommon.parse_cli('templates/ios_show_lldp_neighbors_parse_cli.yaml') }}"

    - name: "MANIPULATE THE DATA TO BE IN STANDARD FORMAT"
      ansible.builtin.set_fact:
        lldp_neighbors: "{{ lldp_neighbors | convert_data_native }}"

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

There are a few things to dissect with the playbook. First, we changed the parser to ansible.netcommon.native. We also added a few tasks to show both the ansible.netcommon.native parser and the ansible.netcommon.parse_cli filter plug-in. Despite using both methods, we’re able to validate that both methods should result in our assertions passing.

Playbook Output

Let’s go ahead and run the playbook and see what output we get.

❯ ansible-playbook pb.validate.neighbors.yml -k -vv
ansible-playbook 2.10.5
  config file = /Users/myohman/Documents/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/main3.8/lib/python3.8/site-packages/ansible
  executable location = /Users/myohman/.virtualenvs/main3.8/bin/ansible-playbook
  python version = 3.8.6 (default, Nov 17 2020, 18:43:06) [Clang 12.0.0 (clang-1200.0.32.27)]
Using /Users/myohman/Documents/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
Skipping callback 'default', as we already have a stdout callback.
Skipping callback 'minimal', as we already have a stdout callback.
Skipping callback 'oneline', as we already have a stdout callback.

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/Documents/local-dev/blog-posts/pb.validate.neighbors.yml:9
ok: [iosv-2] => changed=false 
  ansible_facts:
    lldp_neighbors:
      Gi0/0:
        capabilities: R
        hold_time: 120
        neighbor: iosv-0
        neighbor_interface: Gi0/1
      Gi0/1:
        capabilities: R
        hold_time: 120
        neighbor: iosv-1
        neighbor_interface: Gi0/1
  parsed:
    Gi0/0:
      capabilities: R
      hold_time: 120
      neighbor: iosv-0
      neighbor_interface: Gi0/1
    Gi0/1:
      capabilities: R
      hold_time: 120
      neighbor: iosv-1
      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>
ok: [iosv-0] => changed=false 
  ansible_facts:
    lldp_neighbors:
      Gi0/0:
        capabilities: R
        hold_time: 120
        neighbor: iosv-1
        neighbor_interface: Gi0/0
      Gi0/1:
        capabilities: R
        hold_time: 120
        neighbor: iosv-2
        neighbor_interface: Gi0/0
  parsed:
    Gi0/0:
      capabilities: R
      hold_time: 120
      neighbor: iosv-1
      neighbor_interface: Gi0/0
    Gi0/1:
      capabilities: R
      hold_time: 120
      neighbor: iosv-2
      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-1              Gi0/0          120        R               Gi0/0
    iosv-2              Gi0/1          120        R               Gi0/0
  
    Total entries displayed: 2
  stdout_lines: <omitted>
ok: [iosv-1] => changed=false 
  ansible_facts:
    lldp_neighbors:
      Gi0/0:
        capabilities: R
        hold_time: 120
        neighbor: iosv-0
        neighbor_interface: Gi0/0
      Gi0/1:
        capabilities: R
        hold_time: 120
        neighbor: iosv-2
        neighbor_interface: Gi0/1
  parsed:
    Gi0/0:
      capabilities: R
      hold_time: 120
      neighbor: iosv-0
      neighbor_interface: Gi0/0
    Gi0/1:
      capabilities: R
      hold_time: 120
      neighbor: iosv-2
      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-2              Gi0/1          120        R               Gi0/1
    iosv-0              Gi0/0          120        R               Gi0/0
  
    Total entries displayed: 2
  stdout_lines: <omitted>

TASK [PARSE DATA AND SET_FACT WITH PARSE_CLI FILTER PLUGIN] ********************************************************************************************************************************************************
task path: /Users/myohman/Documents/local-dev/blog-posts/pb.validate.neighbors.yml:17
ok: [iosv-0] => changed=false 
  ansible_facts:
    parse_cli_lldp_neighbors:
      lldp_neighbors:
      - capabilities: R
        hold_time: 120
        local_interface: Gi0/0
        neighbor: iosv-1
        neighbor_interface: Gi0/0
      - capabilities: R
        hold_time: 120
        local_interface: Gi0/1
        neighbor: iosv-2
        neighbor_interface: Gi0/0
ok: [iosv-1] => changed=false 
  ansible_facts:
    parse_cli_lldp_neighbors:
      lldp_neighbors:
      - capabilities: R
        hold_time: 120
        local_interface: Gi0/1
        neighbor: iosv-2
        neighbor_interface: Gi0/1
      - capabilities: R
        hold_time: 120
        local_interface: Gi0/0
        neighbor: iosv-0
        neighbor_interface: Gi0/0
ok: [iosv-2] => changed=false 
  ansible_facts:
    parse_cli_lldp_neighbors:
      lldp_neighbors:
      - capabilities: R
        hold_time: 120
        local_interface: Gi0/1
        neighbor: iosv-1
        neighbor_interface: Gi0/1
      - capabilities: R
        hold_time: 120
        local_interface: Gi0/0
        neighbor: iosv-0
        neighbor_interface: Gi0/1

TASK [MANIPULATE THE DATA TO BE IN STANDARD FORMAT] ****************************************************************************************************************************************************************
task path: /Users/myohman/Documents/local-dev/blog-posts/pb.validate.neighbors.yml:21
ok: [iosv-0] => changed=false 
  ansible_facts:
    lldp_neighbors:
    - capabilities: R
      hold_time: 120
      local_interface: Gi0/0
      neighbor: iosv-1
      neighbor_interface: Gi0/0
    - capabilities: R
      hold_time: 120
      local_interface: Gi0/1
      neighbor: iosv-2
      neighbor_interface: Gi0/0
ok: [iosv-1] => changed=false 
  ansible_facts:
    lldp_neighbors:
    - capabilities: R
      hold_time: 120
      local_interface: Gi0/1
      neighbor: iosv-2
      neighbor_interface: Gi0/1
    - capabilities: R
      hold_time: 120
      local_interface: Gi0/0
      neighbor: iosv-0
      neighbor_interface: Gi0/0
ok: [iosv-2] => changed=false 
  ansible_facts:
    lldp_neighbors:
    - capabilities: R
      hold_time: 120
      local_interface: Gi0/1
      neighbor: iosv-1
      neighbor_interface: Gi0/1
    - capabilities: R
      hold_time: 120
      local_interface: Gi0/0
      neighbor: iosv-0
      neighbor_interface: Gi0/1

TASK [Assert the correct neighbors are seen via Ansible Native Parser] *********************************************************************************************************************************************
task path: /Users/myohman/Documents/local-dev/blog-posts/pb.validate.neighbors.yml:25
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=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
iosv-1                     : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
iosv-2                     : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

If we take a closer look at the output of the first task, we can see under the parsed key as well as setting the fact (lldp_neighbors), that we have our structured data from running the raw output through Ansible native parser as well as a subsequent task using the parse_cli filter plug-in.

We can see from the output, both methods have resulted in our assertions passing.


Conclusion

We went through two of the built-in parsers that Ansible provides and how we can use each of them to parse the data into desired formats to assert on. They both offer different pros and cons. If we use the built-in ansible.netcommon.parse_cli, it fetches the data for us, and then parses it with the template provided by us. This can be desirable rather than having to use the correct *_command module, and then pass the output into the ansible.netcommon.parse_cli filter plug-in. The downside to the coupled approach is that it can be difficult to manipulate the original unstructured data in later tasks and may require fetching the data from the device again.

We could have looked into setting gather_facts to yes at the play level and then using the ansible_net_neighbors facts that provides the CDP/LLDP output as structured data as well.

Below is the output of tree after all the changes discussed above.

❯ tree
.
├── ansible.cfg
├── filter_plugins
│   └── custom_filters.py
├── group_vars
│   ├── all
│   │   └── all.yml
│   └── ios.yml
├── host_vars
│   ├── iosv-0.yml
│   ├── iosv-1.yml
│   └── iosv-2.yml
├── inventory
├── pb.validate.neighbors.yml
└── templates
    ├── ios_show_lldp_neighbors.yaml
    └── ios_show_lldp_neighbors_parse_cli.yaml

Finally the updated contents of the custom_filters.py.

# -*- coding: utf-8 -*-

# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function
__metaclass__ = type


import re

def convert_genie_data(data):

    intfs = []
    for k, v in data['interfaces'].items():
        intf_name = "Gi" + re.search(r'\d/\d', k).group(0)
        intf_dict = {}
        intf_dict['local_interface'] = intf_name
        neighbor_intf = list(v['port_id'].keys())[0]
        intf_dict['neighbor'] = list(v['port_id'][neighbor_intf]['neighbors'].keys())[0]
        intfs.append(intf_dict)

    return intfs

def convert_native_data(data):

    intfs = []
    for intf in data:
        temp_dict = {"local_interface": intf}
        temp_dict.update(data[intf])
        intfs.append(temp_dict)

    return intfs


class FilterModule:
    def filters(self):
        filters = {
            'convert_data': convert_genie_data,
            'convert_data_native': convert_native_data,
        }
        return filters

I hope you enjoyed this blog post and gained a clearer picture of the available built-in parsers provided by Ansible. The next post in this series will go over building our own custom filter plug-in to parse the data.

-Mikhail



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Parsing Strategies – PyATS Genie Parsers

Blog Detail

Thank you for joining me for Part 3 of the parsing strategies blog series. This post will dive deeper into using Cisco’s PyATS Genie library for parsing. For further reference in this blog post, I’ll be referring to the Genie library just by Genie. Genie also uses Regular Expressions (RegEx) under the hood to parse the output received from a device whether it’s semi-structured data, XML, YANG, etc. This is a key difference from other parsers, but for now, let’s stick with parsing semi-structured data.

Let’s move on and dive deeper into what the show lldp neighbors parser looks like, how it works, and how we need to modify our existing playbook to use the Genie parsers.

Genie Primer

Before we get too deep into how Genie works, you can see all the available parsers. The number of parsers has increased dramatically over the last several months and is starting to include more vendors, which is great to see.

Genie uses Python classes to build two important parsing functions:

  • Schema class: This class defines the schema the structured output should adhere to.
  • Parser class: This class defines the actual parsing methods for the specific command.

One key difference between what we’ve covered so far and Genie parsers is the ability to connect to devices and grab the necessary output, which is the default behavior, but also allows users to provide the output instead, thus working like most parsers that are separate from the device interaction.

Another key difference is the ability to use other parsing strategies within Genie such as TextFSM or Template Text Parser (TTP), but for the sake of this post, we will be covering RegEx.

You can find more detailed information on how the Genie parsers work at their developer guide.

Let’s dive into our particular parser.

"""show_lldp.py
   supported commands:
     *  show lldp
     *  show lldp entry *
     *  show lldp entry [<WORD>]
     *  show lldp interface [<WORD>]
     *  show lldp neighbors
     *  show lldp neighbors detail
     *  show lldp traffic
"""
import re

from genie.metaparser import MetaParser
from genie.metaparser.util.schemaengine import Schema, \
                                         Any, \
                                         Optional, \
                                         Or, \
                                         And, \
                                         Default, \
                                         Use

# import parser utils
from genie.libs.parser.utils.common import Common

We can see this parser is declared in the show_lldp.py module and supports several variations of the show lldp commands. It then imports re, which is the built in RegEx library. The next import is the MetaParser that makes sure that the parsers’ output adheres to the defined schema. After MetaParser is imported, the schema related imports take place that helps build the actual schema we’ll see shortly. After that, the Common class that provides helper functions is imported.

The schema class provides us insight into what the output will look like. Let’s take a look at the initial definition.

class ShowLldpNeighborsSchema(MetaParser):
    """
    Schema for show lldp neighbors
    """
    schema = {
        'total_entries': int,
        'interfaces': {
            Any(): {
                'port_id': {
                    Any(): {
                        'neighbors': {
                            Any(): {
                                'hold_time': int,
                                Optional('capabilities'): list,
                            }
                        }
                    }
                }
            }
        }
    }

We can see that ShowLldpNeighborsSchema will be a subclass of the MetaParser class imported at the beginning of the file. Within the ShowLldpNeighborsSchema class, we define our schema attribute used to make sure our output adheres to the schema before returning it to the user.

The schema is a dictionary and is expecting a total_entries key with an integer value and an interfaces key, used to define a dictionary. Each interface will be a key within the interfaces dictionary and the data obtained from the output is defined in several other nested dictionaries. Each key value pair specifies the key and the type of value it must be. There are also Optional keys not required to pass schema validation.

Now that we see the schema, we can mock up what our potential output would be.

{
    "total_entries": 1,
    "interfaces": {
        "GigabitEthernet1": {
            "port_id": {
                "Gi1": {
                    "neighbors": {
                        "iosv-0": {
                            "capabilities": [
                                "R"
                            ],
                            "hold_time": 120
                        }
                    }
                }
            }
        }
    }
}

Let’s move onto the defined parser class.

class ShowLldpNeighbors(ShowLldpNeighborsSchema):
    """
    Parser for show lldp neighbors
    """
    CAPABILITY_CODES = {'R': 'router',
                        'B': 'mac_bridge',
                        'T': 'telephone',
                        'C': 'docsis_cable_device',
                        'W': 'wlan_access_point',
                        'P': 'repeater',
                        'S': 'station_only',
                        'O': 'other'}

    cli_command = ['show lldp neighbors']

We can see the ShowLldpNeighbors class is inheriting the ShowLldpNeighborsSchema class we just covered. Now there is a mapping for the short form capabilities codes returned within the output when neighbors exist, and the long form the parser wants to return to the user.

The next defined variable is cli_command. It specifies the commands executed by the parser if no output is provided.

Let’s take look at the cli method to see what will be executed when a user specifies the cli parser for show lldp neighbors command.

Each type of output will be specified as a method under the parser class. For example, if the device returns xml, there will be an xml method that will parse and return structured data that adheres to the same schema as the cli output.

Let’s explore the code in bite size chunks to show what’s happening.

    def cli(self, output=None):
        if output is None:
            cmd = self.cli_command[0]
            out = self.device.execute(cmd)
        else:
            out = output

        parsed_output = {}

We can see the cli method takes an optional argument named output, but defaults to None. The first logic determines whether the user has provided the output or whether the parser needs to execute the command against the device. The connection the parser uses is provided by the PyATS library. This means no other library is required such as netmiko or napalm to connect to the devices.

        # Total entries displayed: 4
        p1 = re.compile(r'^Total\s+entries\s+displayed:\s+(?P<entry>\d+)$')

        # Device ID           Local Intf     Hold-time  Capability      Port ID
        # router               Gi1/0/52       117        R               Gi0/0/0
        # 10.10.191.107       Gi1/0/14       155        B,T             7038.eeff.572d
        # d89e.f3ff.58fe      Gi1/0/33       3070                       d89e.f3ff.58fe
        p2 = re.compile(r'(?P<device_id>\S+)\s+(?P<interfaces>\S+)'
                        r'\s+(?P<hold_time>\d+)\s+(?P<capabilities>[A-Z,]+)?'
                        r'\s+(?P<port_id>\S+)')

After the parsed_output variable is instantiated the next step is to define the RegEx expressions used to find the valuable data within the device output. Since the output is tabulated, which means it’s defined as a table, all values we care about will be on the same line (row) for each neighbor.

The parser uses re.compile to specify the RegEx expression ahead of time for use later in the code. Typically, re.compile is used when the same expression is used multiple times.

p1 will provide the total_entries within our schema, by using the Named Capturing Groups ability within the re library. Luckily, Cisco provide great documentation within the code to tell you what each RegEx is expecting to capture. p2 defines the RegEx used to capture the neighbor related information. We can see it uses mostly \S+ which captures any non-whitespace since the output is straight forward. But we can see the capabilities named capture group is a bit more complicated. It’s expecting at least one or more capital letter or comma, and then zero or one of that RegEx expression. This may be better explained by their example if we look at the capabilities column, it shows that it can capture a single capability, two capabilities with a comma, or zero capabilities.

Now let’s look at the remaining code to see how it uses these RegEx expressions.

        for line in out.splitlines():
            line = line.strip()

            # Total entries displayed: 4
            m = p1.match(line)
            if m:
                parsed_output['total_entries'] = int(m.groupdict()['entry'])
                continue

            # Device ID           Local Intf     Hold-time  Capability      Port ID
            # router               Gi1/0/52       117        R               Gi0/0/0
            # 10.10.191.107       Gi1/0/14       155        B,T             7038.eeff.572d
            # d89e.f3ff.58fe      Gi1/0/33       3070                       d89e.f3ff.58fe
            m = p2.match(line)
            if m:
                group = m.groupdict()

                intf = Common.convert_intf_name(group['interfaces'])
                device_dict = parsed_output.setdefault('interfaces', {}). \
                                          setdefault(intf, {}). \
                                          setdefault('port_id', {}). \
                                          setdefault(group['port_id'], {}).\
                                          setdefault('neighbors', {}). \
                                          setdefault(group['device_id'], {})

                device_dict['hold_time'] = int(group['hold_time'])

                if group['capabilities']:
                    capabilities = list(map(lambda x: x.strip(), group['capabilities'].split(',')))
                    device_dict['capabilities'] = capabilities


            continue

        return parsed_output

We can see the parser performs a for loop through the output using the splitlines method to provide a list of each line within the output. It will strip any whitespace on either side of the string.

The parser will attempt to match the p1 compiled RegEx and if it captures it, will then add total_entries to the parsed_output dictionary and then continue to the next line in the output.

If the parser didn’t capture anything for p1, it will then attempt to match p2. If a match occurs, it will then the groupdict() method to return all the named capture groups and their values as a dictionary.

We can now see the parser uses the convert_intf_name method from the Common class imported at the top of the file.

You can review the code here.

Once the interface name is converted, the parser adds the interfaces dictionary to the parsed_output variable by extracting the information captured or defaulting to an empty dictionary for any non-captured data and then assigning it to the device_dict variable.

After the device_dict is specified, it adds the hold time to it.

The next step is to strip and split the capabilities into a list and add to the device_dict variable. The parser will then continue to the next line of the output.

Once all lines are parsed, it will return the parsed_output to the user as long as it passes schema validation.

I believe this can be easier to understand than something like TextFSM since it’s written in Python and Python is a popular language among network automation engineers.

Let’s move on and review the topology again.

The Topology.. Again

Below is a picture of the lab topology we’re using to validate LLDP neighbors. It’s a simple topology with three Cisco IOS routers connected together and have LLDP enabled.

BlogPostTopology

Ansible Setup.. Again

We’ve already covered most of the Ansible setup in part 2, but we’ll explain the small changes we have to make to use the Genie parsers within Ansible.

Here is a look at a host var we’ve defined as a refresher since there are no changes here.

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

Now let’s take a look at the changes in 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.pyats
        set_fact: "lldp_neighbors"

    - name: "MANIPULATE THE DATA TO BE IN THE SAME FORMAT AS TEXTFSM TO PREVENT CHANGING FINAL ASSERTION TASK"
      set_fact:
        lldp_neighbors: "{{ lldp_neighbors | convert_data }}"

    - 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 }}"

Using the ansible.netcommon.pyats parser requires genie and pyats to be installed via pip install genie pyats.

There are a few things to dissect with the playbook. First, we changed the parser to ansible.netcommon.pyats. Second, we added another task to manipulate the data we get back from the parser, into a similar format as the second blog post in this series so we don’t have to change the last task. I did the conversion within a custom filter plugin due to the structure of the data and the ease of handling this within Python. You will see the output below once we run our playbook.

Playbook Output

Let’s go ahead and run the playbook and see what output we get.

parsing-ansible-pyats
❯ ansible-playbook pb.validate.neighbors.yml -k -vv
ansible-playbook 2.10.3
  config file = /Users/myohman/Documents/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/3.8/main/lib/python3.8/site-packages/ansible
  executable location = /Users/myohman/.virtualenvs/3.8/main/bin/ansible-playbook
  python version = 3.8.6 (default, Nov 17 2020, 18:43:06) [Clang 12.0.0 (clang-1200.0.32.27)]
Using /Users/myohman/Documents/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
Skipping callback 'default', as we already have a stdout callback.
Skipping callback 'minimal', as we already have a stdout callback.
Skipping callback 'oneline', as we already have a stdout callback.

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/Documents/local-dev/blog-posts/pb.validate.neighbors.yml:9
ok: [iosv-2] => changed=false
  ansible_facts:
    lldp_neighbors:
      interfaces:
        GigabitEthernet0/0:
          port_id:
            Gi0/1:
              neighbors:
                iosv-0:
                  capabilities:
                  - R
                  hold_time: 120
        GigabitEthernet0/1:
          port_id:
            Gi0/1:
              neighbors:
                iosv-1:
                  capabilities:
                  - R
                  hold_time: 120
      total_entries: 2
  parsed:
    interfaces:
      GigabitEthernet0/0:
        port_id:
          Gi0/1:
            neighbors:
              iosv-0:
                capabilities:
                - R
                hold_time: 120
      GigabitEthernet0/1:
        port_id:
          Gi0/1:
            neighbors:
              iosv-1:
                capabilities:
                - R
                hold_time: 120
    total_entries: 2
  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>
ok: [iosv-0] => changed=false
  ansible_facts:
    lldp_neighbors:
      interfaces:
        GigabitEthernet0/0:
          port_id:
            Gi0/0:
              neighbors:
                iosv-1:
                  capabilities:
                  - R
                  hold_time: 120
        GigabitEthernet0/1:
          port_id:
            Gi0/0:
              neighbors:
                iosv-2:
                  capabilities:
                  - R
                  hold_time: 120
      total_entries: 2
  parsed:
    interfaces:
      GigabitEthernet0/0:
        port_id:
          Gi0/0:
            neighbors:
              iosv-1:
                capabilities:
                - R
                hold_time: 120
      GigabitEthernet0/1:
        port_id:
          Gi0/0:
            neighbors:
              iosv-2:
                capabilities:
                - R
                hold_time: 120
    total_entries: 2
  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:
      interfaces:
        GigabitEthernet0/0:
          port_id:
            Gi0/0:
              neighbors:
                iosv-0:
                  capabilities:
                  - R
                  hold_time: 120
        GigabitEthernet0/1:
          port_id:
            Gi0/1:
              neighbors:
                iosv-2:
                  capabilities:
                  - R
                  hold_time: 120
      total_entries: 2
  parsed:
    interfaces:
      GigabitEthernet0/0:
        port_id:
          Gi0/0:
            neighbors:
              iosv-0:
                capabilities:
                - R
                hold_time: 120
      GigabitEthernet0/1:
        port_id:
          Gi0/1:
            neighbors:
              iosv-2:
                capabilities:
                - R
                hold_time: 120
    total_entries: 2
  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>

TASK [MANIPULATE THE DATA TO BE IN STANDARD FORMAT] *******************************************************
task path: /Users/myohman/Documents/local-dev/blog-posts/pb.validate.neighbors.yml:16
ok: [iosv-0] => changed=false
  ansible_facts:
    lldp_neighbors:
    - local_interface: Gi0/1
      neighbor: iosv-2
    - local_interface: Gi0/0
      neighbor: iosv-1
ok: [iosv-1] => changed=false
  ansible_facts:
    lldp_neighbors:
    - local_interface: Gi0/1
      neighbor: iosv-2
    - local_interface: Gi0/0
      neighbor: iosv-0
ok: [iosv-2] => changed=false
  ansible_facts:
    lldp_neighbors:
    - local_interface: Gi0/1
      neighbor: iosv-1
    - local_interface: Gi0/0
      neighbor: iosv-0

TASK [Assert the correct neighbors are seen] **************************************************************
task path: /Users/myohman/Documents/local-dev/blog-posts/pb.validate.neighbors.yml:20
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-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-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/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=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
iosv-1                     : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
iosv-2                     : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

I ran this playbook with some verbosity to show what each task returns and the format of our parsed data.

If we take a closer look at the output of the first task, we can see under the parsed key as well as setting the fact (lldp_neighbors), that we have our structured data from running the raw output through Genie.

The second task shows the loop for each host and the item it’s using during the loop. If you look back at our playbook, we’re using both the local_intf and neighbor for our assertions from our approved_neighbors variable.

Summary

We converted the data using a custom filter plugin, but we could have easily adjusted the facts and final assertions to align with the output we receive back from Genie. It’s also valuable to show the possibility of having a single playbook using any parser to run operational assertions. If we were in production, we could make the convert_data custom filter plugin translate several different parser formats into a parser agnostic format.

For brevity, here is our tree output to show you what the folder structure looks like to use a custom filter plugin.

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

Finally the contents of the custom_filters.py.

# -*- coding: utf-8 -*-

from __future__ import absolute_import, division, print_function
__metaclass__ = type


import re

def convert_genie_data(data):

    intfs = []
    for k,v in data['interfaces'].items():
        intf_name = "Gi" + re.search(r'\d/\d', k).group(0)
        intf_dict = {}
        intf_dict['local_interface'] = intf_name
        neighbor_intf = list(v['port_id'].keys())[0]
        intf_dict['neighbor'] = list(v['port_id'][neighbor_intf]['neighbors'].keys())[0]
        intfs.append(intf_dict)

    return intfs


class FilterModule:
    def filters(self):
        filters = {
            'convert_data': convert_genie_data,
        }
        return filters

Conclusion

I hope you enjoyed this blog post and understand a little bit more about Genie parsers and how to consume them with Ansible. The next post in this series will go over Ansible Engine parsing.

-Mikhail



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Parsing Strategies – NTC Templates using TextFSM

Blog Detail

Thank you for joining me for Part 2 of the parsing strategies blog series. This post will dive deeper into using NTC Templates to parse unstructured data into usable, structured data. NTC Templates uses TextFSM under the hood to be able to parse the data obtained from traditional networking devices by using Regular Expressions (RegEx). We will go over what a TextFSM template looks like, how it works, and how we can use the template in Ansible to perform topology assertions against our lab topology.

TextFSM Primer

TextFSM was created by Google to process semi-structured data from network devices into structured data that can be easily accessed programmatically. TextFSM is a Domain Specific Language (DSL) using RegEx under the hood to parse the data. This means that some RegEx knowledge is required, but there are helpful websites such as https://regexr.com or https://regex101.com that can help get you started with understanding RegEx. These websites are a great help when it comes troubleshooting my TextFSM templates and why something may not be capturing the way I would expect it to.

Let’s take a look at a TextFSM template and then break it down to better understand how TextFSM works. Below is the cisco_ios_show_lldp_neighbors.textfsm template from NTC Templates that we will be using for this post.

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

We’ll cover the template it more detail below, but I want to show you what the raw data looks like and how it looks after it has been parsed with 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

Here is the output returned after parsing semi-structured data using NTC Templates.

---
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"

Values

As you can see in the output we received, the Value in the template is used as the key (column) in each dictionary (row) that is returned.

There are a few keywords that can modify how the value is processed, such as the following:

  • Required: The record (row) is only saved into the table if this value is matched.
  • Filldown: The previously matched value is retained for subsequent records (unless explicitly cleared or matched again). In other words, the most recently matched value is copied to newer rows unless matched again.
  • List: The value is a list, appended to on each match. Normally a match will overwrite any previous value in that row.
  • Key: Declares that the field’s contents contribute to the unique identifier for a row. This can be used to tie data from multiple templates together into a single structure.
  • Fillup: Like Filldown, but populates upwards until it finds a non-empty entry. Not compatible with Required.

At the end of the line is where we will specify the RegEx that will match our semi-structured text for that specific Value. Depending on the data, this can be as generic as \S+, when the data is well known, or as complex as needed.

\S+ matches any non-whitespace which requires the data to either be controlled or well known as stated above.

To recap what we just discussed, here is a breakdown of a Value line: Value {KEYWORD} {VALUE_NAME} (RegEx).

States

The State definitions come after the Value definitions and are separated from the values by a blank line. The lines indented after each State are the state rules that are specified to match the Value definitions specified at the beginning of the template. States are helpful to break up your template into easier to read chunks if the semi-structured data is complex. There is no upper limit to the amount of states you have in your TextFSM template, but Start is always required.

State Rules

The rules define the lines we want to capture with the Value definitions at the beginning of the template. Each rule line must start with a carat (^). The rules don’t have to end with a -> rule action, but it might be required depending on the data. The -> denotes a rule action and tells TextFSM what to do with the data captured up to this point. We will discuss the rule actions shortly. The values are denoted by ${VALUE_NAME} within the state rules which will be expanded out with the RegEx from the Value definition.

Keep in mind that the whole line does not have to be RegEx or values (${NEIGHBOR}), but can also be regular text to match on. Behind the scenes, TextFSM converts each rule to a complete RegEx string. If we take a look at the first line under the LLDP state, it would look like the following behind the scenes: ^(\S{0,20})\s*(\S+)\s+\d+\s+(\S*)\s+(\S+)

The state rule does not have to match the whole line which we can see within our template with the ` . ^Load\s+for\s+, which will match any line that starts with Load for `.

Rule and Line Actions

Rule actions can be applied per state rule line, but will affect the behavior and the placement within the states must be considered carefully. There are line actions which tell TextFSM to do with the current line while it’s processing and then rule actions tell TextFSM what to do with the captured values. Per the default action, any line that does not contain a ->, is Next.NoRecord. To better understand this, let’s dive into what options we have when it comes to using the line and rule actions by specifying ->.

Line Actions

  • Next (Default): Finish with the input line, read in the next line and start matching again from the start of the state. This is the default behavior if no line action is specified.
  • Continue: Retain the current line and do not resume matching from the first rule of the state. Continue processing rules as if a match did not occur (value assignments still occur).

Using the Continue line action is not a common use case when building a template, but taking a look at the cisco_ios_show_vlan.textfsm shows a use case when you want to capture multiple values that are on the same line.

Here is an example of the template:

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

Here is an example of the semi-structured data that will be parsed:

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

By using the Continue for each line, we can keep the value we captured, as well as the line it’s currently processing, and then move onto the next state rule within the State to capture the additional values on the line.

This means our structured data will look like the following:

---
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"

Rule Actions

  • NoRecord (Default): Do nothing. This is the default behavior if no record action is specified.
  • Record: Record the values collected so far as a row in the return data. Non Filldown values are cleared. Note: No record will be output if there are any ‘Required’ values that are unassigned.
  • Clear: Clear non Filldown values.
  • Clearall: Clear all values.
  • State: Transition to a different state.
  • Error: This is a built-in state and will discard any captured values and return an exception.

We use the Error rule action to help troubleshoot our templates and make sure our templates are accounting for the correct data. Here is the way we use it: ` ^. -> Error`, which will provide an exception with the line that does not match any defined state rule. If you have any other questions, we provide a FAQ

` -> Continue.State` is not allowed to prevent loops within TextFSM.

If we look at the template, under the LLDP state, we see two -> Record options within it. This allows us to capture the appropriate values, but by parsing slightly different output.

We’re also able to combine the line action with the rule action. The syntax for this is LineAction.RuleAction. You can take a look at this template to see it in action and look at the raw data to get a better idea of how it’s being used.

Hopefully this overview of TextFSM provides a clearer picture when deciphering a template or when you start to create your own. Let’s move onto a quick overview of the topology and then get right into our verification playbook.

The definitions provided for value keywords, line and rule actions above were taken from the Google TextFSM wiki.

The Topology

Below is a picture of the topology we will be using to validate LLDP neighbors within our lab topology. This is a simple topology with three Cisco IOS routers that are connected together and have LLDP enabled.

BlogPostTopology

Ansible Setup

Luckily our topology and the subsequent inventory will both be simple. We have the routers in a group called ios which then has a corresponding ios.yml file within the group_vars folder that has variables pertinent to connecting to these devices that I will show shortly. We then have {hostname}.yml files for each router that hold the approved_neighbors variable that we will use to validate the neighbors we see from our parsed LLDP data. Below is the tree of our directory that contains the Ansible playbook and inventory.

❯ 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

Here is the inventory file that ties our routers to the ios group.

[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

Here is the contents of ios.yml file. This specifies the user we want to connect to the routers with a well as the ansible_network_os to tell Ansible what kind of networking device we will be connecting to.

If you notice, this is different than Ansible <= 2.9 syntax and uses a path to cisco.ios.ios rather than just ios. This is because we’re running Ansible 2.10 and migrating to using the syntax that Ansible will be enforcing in the future. You will notice some differences with using Ansible 2.10 in our playbook as well.

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

Here is a look at a host var we have defined.

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

Now let’s take a look at 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 }}"

The playbook starts off with defining our hosts as the ios group in our inventory file which consists of our three IOS routers. The connection method uses the >= Ansible 2.10 syntax for network_cli and we have disabled gathering facts.

Let’s breakdown the two tasks we’re using to assert our operational data for LLDP neighbors matches the defined approved_neighbors variable we have for each host.

The first task uses the ansible.netcommon.cli_parse module to run the command against the device and then parse the data with our defined ansible.netcommon.ntc_templates parser. This output is saved as lldp_neighbors due to the set_fact directive on the task.

If you’re looking to run this same playbook, make sure you have ntc-templates installed via pip install ntc-templates.

The next task will loop over our approved_neighbors variable and then attempt to find a match in our parsed data by looking for an entry that has a value for the key local_interface that matches what we have set for local_intf in approved_neighbors and that the neighbor key also matches our neighbor value. Our playbook will fail if any of the neighbors do not match what we have defined in approved_neighbors.

The way Ansible is evolving and the methodologies we have used within the playbook do not limit the playbook’s potential to just ios and we can in fact swap out the ios hosts definition for all or any number of groups and hosts that are multivender. This is due to the magic behind the scenes that ansible.netcommon.cli_parse is performing with the ansible_network_os variable we set in group vars. It uses that variable to determine which nos_command module to run to connect to the device and which template to use to parse the returned data.

Playbook Output

Let’s go ahead and run the playbook and see what output we get.

parsing-ansible-ntc-templates
❯ 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

I ran this playbook with some verbosity to be able to show what each task returns and the format of our parsed data.

If we take a closer look at the output of the first task, we can see under the parsed key as well as setting the fact (lldp_neighbors), that we have our structured data from running the raw output through NTC Templates.

The second task shows the loop for each host and the item that it is using during the loop. If you look back at our playbook, we’re using both the local_intf and neighbor for our assertions from our approved_neighbors variable.


Conclusion

I hope you enjoyed this blog post and understand a little bit more about TextFSM, NTC Templates, and how easy they are to use with Ansible. The ease of use is not unique to Ansible as this can also easily be achieved with Netmiko or raw Python, but used Ansible due to the industry adoption of Ansible. The next post in this series will be going over PyATS Genie parsing and using it within Ansible. Our biggest change in the next post will be the assertions as the structured data will not be the same as NTC Templates and that is OK. The point of these posts it to help you decide which parsing strategies you can use throughout your journey and which ones may be more suitable for specific situations.

-Mikhail



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!