Skip to content

Portal | Level: L2: Operations | Topics: Network Automation | Domain: Networking

Network Automation — Primer

Why This Matters

SSH screen-scraping was the default network automation approach for a decade: connect, send CLI commands, parse the output with fragile regex. This breaks in production because vendor CLI output changes between software versions, multi-line responses are non-deterministic, and there is no transactional safety — if your script dies halfway through pushing 40 interface configs, you have no rollback. Automation built on text parsing is maintenance debt that compounds with every OS upgrade.

The industry has moved to structured data interfaces: NETCONF (RFC 6241) for config management, RESTCONF (RFC 8040) for HTTP access to the same data models, gNMI for high-frequency streaming telemetry, and YANG (RFC 7950) as the data modeling language that makes all three consistent across vendors. NAPALM abstracts multi-vendor differences behind a Python API. Nornir replaces Ansible's overhead with a pure-Python task runner. Netmiko remains necessary for legacy devices that predate structured APIs.

Understanding why these tools exist — and when each one is the right choice — is what separates an engineer who can write a working script from one who can build a reliable automation platform. Idempotency, diff/commit/rollback, and inventory-driven execution are the foundations.

Core Concepts

1. Why Screen-Scraping Breaks

CLI output is designed for humans. It changes silently between versions:

# IOS 15.x output:
GigabitEthernet0/0 is up, line protocol is up

# IOS-XE 16.x output:
GigabitEthernet0/0 is up, line protocol is up (connected)

The extra (connected) breaks your regex. NETCONF/YANG returns structured XML or JSON; the schema is versioned, and changes are explicit. Always prefer structured APIs when the device supports them.

Decision tree: - Device supports NETCONF/RESTCONF? → Use NAPALM or ncclient directly - Device supports SSH but not NETCONF? → Use Netmiko + TextFSM - Device is legacy/embedded (serial console, no SSH)? → Netmiko with expect-style interactions - Need high-frequency streaming (interface counters every 10s)? → gNMI

2. NAPALM — Multi-Vendor Abstraction

Name origin: NAPALM stands for Network Automation and Programmability Abstraction Layer with Multivendor support. Created by David Barroso (Spotify) and Elisa Jasinska in 2015 to solve the problem of every network team writing their own vendor-specific abstraction layer.

NAPALM wraps NETCONF, SSH, and vendor APIs behind a unified Python interface. It supports EOS, IOS, IOS-XR, NX-OS, JunOS out of the box.

from napalm import get_network_driver

driver = get_network_driver("eos")
device = driver(
    hostname="spine1.lab.example.com",
    username="netops",
    password="s3cur3",
    optional_args={"port": 443},
)

device.open()

# Getters — read structured state
facts = device.get_facts()
# {'vendor': 'Arista', 'model': 'DCS-7050TX-64', 'os_version': '4.28.2F', ...}

interfaces = device.get_interfaces()
# {'Ethernet1': {'is_up': True, 'is_enabled': True, 'speed': 10000, ...}}

bgp_neighbors = device.get_bgp_neighbors()

# Config management with diff and commit
device.load_merge_candidate(filename="changes/spine1-add-loopback.conf")
diff = device.compare_config()
print(diff)
# +interface Loopback100
# +   ip address 10.255.255.1/32

if diff:
    device.commit_config()   # push the change
    # device.discard_config()  # rollback if diff looks wrong

device.close()

Key methods: | Method | What it does | |--------|-------------| | get_facts() | Hostname, vendor, model, serial, OS version, uptime | | get_interfaces() | Per-interface state, speed, MTU, counters | | get_bgp_neighbors() | BGP peer state, prefixes sent/received | | get_route_to(destination) | RIB lookup for a prefix | | load_merge_candidate(filename) | Stage a partial config merge | | load_replace_candidate(filename) | Stage a full config replace | | compare_config() | Show diff between candidate and running config | | commit_config() | Apply staged config | | discard_config() | Throw away staged config | | rollback() | Revert to previous config (where supported) |

3. Nornir — Inventory-Driven Task Runner

Nornir is a pure-Python framework for running tasks against many devices concurrently. Unlike Ansible, it runs in-process, so you can use Python debuggers, profilers, and libraries freely.

Directory layout:

automation/
  config.yaml          # Nornir configuration
  inventory/
    hosts.yaml         # Device inventory
    groups.yaml        # Group variables
    defaults.yaml      # Fallback variables
  tasks/
    backup_configs.py

config.yaml:

inventory:
  plugin: SimpleInventory
  options:
    host_file: inventory/hosts.yaml
    group_file: inventory/groups.yaml
    defaults_file: inventory/defaults.yaml
runner:
  plugin: threaded
  options:
    num_workers: 20
logging:
  enabled: true
  level: INFO

hosts.yaml:

spine1:
  hostname: 192.168.1.1
  platform: eos
  groups:
    - spine
    - datacenter-a

leaf1:
  hostname: 192.168.1.10
  platform: eos
  groups:
    - leaf
    - datacenter-a

core-rtr-01:
  hostname: 10.0.0.1
  platform: ios
  groups:
    - core

groups.yaml:

spine:
  username: netops
  password: "{{ vault_password }}"
  data:
    role: spine
    backup: true

leaf:
  username: netops
  password: "{{ vault_password }}"
  data:
    role: leaf

Running tasks with F() filter:

from nornir import InitNornir
from nornir.core.filter import F
from nornir_napalm.plugins.tasks import napalm_get
from nornir_utils.plugins.functions import print_result

nr = InitNornir(config_file="config.yaml")

# Filter to only spines in datacenter-a
spines = nr.filter(F(groups__contains="spine") & F(groups__contains="datacenter-a"))

result = spines.run(
    task=napalm_get,
    getters=["facts", "interfaces", "bgp_neighbors"],
)
print_result(result)

Custom task with error handling:

from nornir.core.task import Task, Result
from nornir_netmiko.tasks import netmiko_send_command

def backup_config(task: Task) -> Result:
    result = task.run(
        task=netmiko_send_command,
        command_string="show running-config",
    )
    config = result[0].result
    filename = f"backups/{task.host.name}_{datetime.now():%Y%m%d}.txt"
    Path(filename).write_text(config)
    return Result(host=task.host, result=f"Backed up to {filename}")

# Run with error handling
results = nr.run(task=backup_config)
for host, result in results.items():
    if result.failed:
        print(f"FAILED: {host}{result.exception}")

4. Netmiko — SSH to Legacy Devices

Netmiko handles SSH connections with device-specific quirks (pagination, enable mode, config mode transitions). Use it when NAPALM or NETCONF are not available.

from netmiko import ConnectHandler

device = {
    "device_type": "cisco_ios",
    "host": "10.0.1.1",
    "username": "netops",
    "password": "s3cur3",
    "secret": "enablepass",  # enable password
    "timeout": 30,
}

with ConnectHandler(**device) as conn:
    conn.enable()  # enter privileged exec

    # Show command
    output = conn.send_command("show ip bgp summary")
    print(output)

    # Config changes
    config_commands = [
        "interface GigabitEthernet0/1",
        "description Uplink to spine1",
        "ip address 10.100.1.2 255.255.255.252",
        "no shutdown",
    ]
    output = conn.send_config_set(config_commands)

    # Save config
    conn.save_config()  # sends 'write memory' or 'copy run start'

TextFSM parsing — structured output from show commands:

# netmiko integrates TextFSM via ntc-templates
output = conn.send_command("show ip interface brief", use_textfsm=True)
# Returns list of dicts instead of raw string:
# [{'intf': 'GigabitEthernet0/0', 'ipaddr': '10.0.0.1', 'status': 'up', 'proto': 'up'}, ...]

for intf in output:
    if intf["status"] == "down":
        print(f"DOWN: {intf['intf']}{intf['ipaddr']}")

Device type strings (common): | Platform | device_type | |----------|-------------| | Cisco IOS/IOS-XE | cisco_ios | | Cisco NX-OS | cisco_nxos | | Cisco IOS-XR | cisco_xr | | Arista EOS | arista_eos | | Juniper JunOS | juniper_junos | | Palo Alto | paloalto_panos | | Linux | linux |

5. YANG Models and NETCONF/RESTCONF

Name origin: YANG stands for "Yet Another Next Generation." The name is self-deprecating — it was one of several attempts to create a network data modeling language, but it became the one that stuck when it was standardized as RFC 6020 in 2010 and updated as RFC 7950 in 2016.

YANG is a data modeling language. A YANG model describes what configuration and state data looks like — data types, constraints, hierarchical structure — similar to how JSON Schema describes JSON.

// Simplified interface YANG model (ietf-interfaces)
module ietf-interfaces {
  container interfaces {
    list interface {
      key "name";
      leaf name {
        type string;
      }
      leaf description {
        type string;
      }
      leaf enabled {
        type boolean;
        default true;
      }
      leaf-list higher-layer-if {
        type leafref { path "/interfaces/interface/name"; }
      }
    }
  }
}

NETCONF via ncclient:

from ncclient import manager
from lxml import etree

with manager.connect(
    host="10.0.0.1",
    port=830,
    username="netops",
    password="s3cur3",
    hostkey_verify=False,
) as m:
    # Get running config for interfaces
    filter_xml = """
    <filter type="subtree">
      <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces"/>
    </filter>
    """
    result = m.get_config(source="running", filter=filter_xml)
    print(etree.tostring(result.data, pretty_print=True).decode())

    # Push a config change
    config_xml = """
    <config>
      <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces">
        <interface>
          <name>GigabitEthernet0/1</name>
          <description>Uplink to spine1</description>
          <enabled>true</enabled>
        </interface>
      </interfaces>
    </config>
    """
    m.edit_config(target="candidate", config=config_xml)
    m.commit()

RESTCONF (HTTP/JSON):

# Get interface config
curl -s -u netops:s3cur3 \
  -H "Accept: application/yang-data+json" \
  https://router1/restconf/data/ietf-interfaces:interfaces/interface=GigabitEthernet0%2F1

# Patch interface description
curl -s -u netops:s3cur3 \
  -X PATCH \
  -H "Content-Type: application/yang-data+json" \
  -d '{"ietf-interfaces:interface": {"name": "GigabitEthernet0/1", "description": "Uplink-spine1"}}' \
  https://router1/restconf/data/ietf-interfaces:interfaces/interface=GigabitEthernet0%2F1

6. gNMI and OpenConfig Telemetry

Under the hood: gNMI uses gRPC (HTTP/2 + Protocol Buffers) rather than XML/SSH like NETCONF. This makes it significantly more efficient for high-frequency streaming — a single gNMI subscription can push interface counter updates every few seconds across thousands of interfaces without the parsing overhead of XML.

gNMI (gRPC Network Management Interface) is the streaming telemetry protocol. It replaces SNMP polling for real-time operational data.

# Using gNMIc (CLI tool) for ad-hoc telemetry
# Subscribe to interface counters at 10-second intervals
gnmic subscribe \
  --address spine1:6030 \
  --username netops \
  --password s3cur3 \
  --path "openconfig-interfaces:interfaces/interface[name=*]/state/counters" \
  --mode stream \
  --stream-mode sample \
  --sample-interval 10s \
  --format flat

OpenConfig path examples:

/interfaces/interface[name=Ethernet1]/state/counters/in-octets
/network-instances/network-instance[name=default]/protocols/protocol[name=BGP]/bgp/neighbors/neighbor[neighbor-address=10.0.0.1]/state/session-state
/system/memory/state/used

7. Building Idempotent Network Configs

Idempotency means running the same automation twice produces the same result. This requires diff-before-commit and declarative intent.

# Idempotent VLAN provisioning with NAPALM
def ensure_vlan(device, vlan_id: int, vlan_name: str) -> bool:
    """Add VLAN if it doesn't exist. Return True if a change was made."""
    vlans = device.get_vlans()
    if str(vlan_id) in vlans:
        current_name = vlans[str(vlan_id)]["name"]
        if current_name == vlan_name:
            return False  # Already correct, no change needed

    config = f"vlan {vlan_id}\n  name {vlan_name}\n"
    device.load_merge_candidate(config=config)
    diff = device.compare_config()
    if diff:
        device.commit_config()
        return True
    device.discard_config()
    return False

8. Ansible Network Modules

# Playbook: push interface descriptions to Arista switches
---
- name: Configure interface descriptions
  hosts: arista_switches
  gather_facts: no
  connection: network_cli

  vars:
    ansible_network_os: eos
    ansible_user: netops
    ansible_password: "{{ vault_password }}"
    ansible_become: yes
    ansible_become_method: enable

  tasks:
    - name: Set interface descriptions
      arista.eos.eos_interfaces:
        config:
          - name: Ethernet1
            description: "Uplink to spine1"
            enabled: true
          - name: Ethernet2
            description: "Server rack A port 1"
            enabled: true
        state: merged

    - name: Save running config
      arista.eos.eos_command:
        commands:
          - write memory

    - name: Validate interfaces are up
      arista.eos.eos_interfaces:
        state: gathered
      register: interface_state

    - name: Assert critical interfaces are up
      assert:
        that:
          - item.enabled == true
        fail_msg: "Interface {{ item.name }} is disabled"
      loop: "{{ interface_state.gathered }}"
      when: item.name.startswith('Ethernet1')

Resource modules vs free-form: - eos_interfaces, ios_bgp_global, nxos_vlans — declarative, idempotent, support merged/replaced/deleted/overridden states - ios_command / eos_command — free-form, not idempotent, use only for show commands and verifications

9. Testing Network Changes

# Pre/post change testing with NAPALM
import json
from datetime import datetime

def capture_state(device, label: str) -> dict:
    """Capture device state snapshot for comparison."""
    state = {
        "timestamp": datetime.now().isoformat(),
        "label": label,
        "facts": device.get_facts(),
        "bgp_neighbors": device.get_bgp_neighbors(),
        "interfaces": {
            k: v for k, v in device.get_interfaces().items()
            if v["is_up"]  # only capture up interfaces
        },
        "route_count": len(device.get_routes_to("0.0.0.0/0")),
    }
    with open(f"state_{label}_{device.hostname}.json", "w") as f:
        json.dump(state, f, indent=2)
    return state

def compare_states(pre: dict, post: dict) -> list[str]:
    """Return list of differences between pre/post state."""
    issues = []
    pre_bgp = set(pre["bgp_neighbors"].get("global", {}).get("peers", {}).keys())
    post_bgp = set(post["bgp_neighbors"].get("global", {}).get("peers", {}).keys())

    lost_peers = pre_bgp - post_bgp
    if lost_peers:
        issues.append(f"Lost BGP peers: {lost_peers}")

    pre_up = set(pre["interfaces"].keys())
    post_up = set(post["interfaces"].keys())
    went_down = pre_up - post_up
    if went_down:
        issues.append(f"Interfaces went down: {went_down}")

    return issues

Quick Reference

# NAPALM quick test from CLI
python3 -c "
from napalm import get_network_driver
d = get_network_driver('eos')('spine1.lab', 'netops', 'pass')
d.open()
import json; print(json.dumps(d.get_facts(), indent=2))
d.close()
"

# Nornir filter syntax
nr.filter(F(hostname__contains="spine"))      # substring match
nr.filter(F(groups__contains="datacenter-a")) # group membership
nr.filter(F(platform="eos"))                  # exact match
nr.filter(F(data__role="spine"))              # data field match
nr.filter(F(groups__contains="spine") & F(platform="eos"))  # AND
nr.filter(F(platform="eos") | F(platform="ios"))  # OR
nr.filter(~F(groups__contains="decommissioned"))  # NOT

# Netmiko device types
cisco_ios, cisco_nxos, cisco_xr, cisco_asa
arista_eos, juniper_junos
paloalto_panos, fortinet, vyos
linux, autodetect  # autodetect tries to identify platform

# NETCONF port and capability check
nmap -p 830 10.0.0.1
ssh -p 830 -s netops@10.0.0.1 netconf  # raw NETCONF session

# gNMI capabilities
gnmic capabilities --address spine1:6030 -u netops -p pass --insecure

# pyATS/Genie quick parse
from genie.testbed import load
from genie.conf.base import Device
# Or from CLI:
pyats parse "show ip bgp summary" --testbed-file testbed.yaml --device rtr1

NAPALM driver map: | Vendor/OS | Driver | |-----------|--------| | Arista EOS | eos | | Cisco IOS/IOS-XE | ios | | Cisco IOS-XR | iosxr | | Cisco NX-OS | nxos (SSH) or nxos_ssh | | Juniper JunOS | junos |


Wiki Navigation

Prerequisites