LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

CCNA: Network Automation and Programmability

ccnanetworkingautomationpythonansiblenetmikonapalmrest-apicisco

There is a section at the end of the CCNA 200-301 exam blueprint that has caused more than a few candidates to quietly panic. It is labeled “Automation and Programmability” and it sits there, weighted at roughly ten percent, asking you to explain REST APIs, compare data serialization formats, and describe what Cisco Catalyst Center actually does. For a candidate who spent the previous six months mastering subnetting, spanning tree, and OSPF LSA types, the sudden context switch to Python code and JSON payloads can feel disorienting.

This guide is written to fix that. The CCNA automation domain is not asking you to become a software developer. It is asking you to understand why the industry is moving in this direction, what the tooling looks like from the outside, and how to read and reason about the artifacts these tools produce. That is a realistic goal, and by the end of this post you will have solid footing across every topic the blueprint covers — with enough practical depth to actually use the tools rather than just describe them on an exam.


Why Automation Matters — The Scale Problem

Let us start with an honest framing of the problem, because the “why” determines everything else.

Imagine you are the sole network engineer at a company with ten Cisco IOS switches. You need to add VLAN 200 with the name “GUEST_WIFI” to all ten switches. You SSH into switch-01, type six lines of configuration, verify it, and move on. You do the same for switch-02 through switch-10. An hour later, you are done. It was tedious but manageable. You probably made no mistakes because ten devices is a number a careful human can handle.

Now imagine you have inherited responsibility for a campus network with 600 access-layer switches spread across a dozen buildings, plus 80 distribution switches and a dozen core routers. Add VLAN 200 to all of them. The math becomes unforgiving: if each device takes five minutes of focused work, you are looking at over fifty hours of pure CLI time, and that is before accounting for the cognitive load of staying accurate across hundreds of sequential logins. At that scale, human error is not a risk — it is a statistical certainty. You will mistype a VLAN name. You will forget a switch. You will apply the wrong config to the wrong device because one SSH session looks exactly like every other SSH session.

This is what network engineers call the scale problem, and it is the foundational reason automation exists. The economics of running infrastructure with hundreds or thousands of devices make manual box-by-box CLI management structurally unsustainable.

Configuration drift is the slow-motion consequence of manual management. Over months and years, each individual device accumulates tiny deviations from its original intended state. Someone logs in to troubleshoot an incident and leaves a debug command enabled. A colleague adds an ACL entry and does not document it. Spanning tree tuning happens on three switches but not the fourth. Gradually, your “identical” access layer switches are no longer identical, and the next time you need to reason about the network — during an outage, during an audit, during a hardware replacement — you discover that your documentation bears only a passing resemblance to reality. The running configs have diverged.

Automation solves this by making the desired state explicit and repeatable. You write a template that describes exactly what a properly configured access switch should look like. You apply that template with a tool. Every device receives exactly the same configuration from the same source. When the tool runs again tomorrow, it checks whether the device state matches the desired state and reports — or corrects — any deviations. The authoritative source of truth is now a file in version control, not someone’s mental model or a two-year-old spreadsheet.

Speed matters too, and not just for convenience. Modern businesses move fast. A new office needs network access by Friday. A security team identifies a vulnerable cipher suite that needs to be disabled on every SSH server across the infrastructure. A compliance requirement mandates that a new syslog server be added to every device. With manual processes these become multi-day or multi-week projects. With automation they become jobs that run in minutes. The business notices the difference.

The CCNA exam includes automation because the industry has already moved this way. Cisco has been explicit in its platform strategy: Catalyst Center is the control plane for the enterprise campus, IOS-XE exposes YANG-modeled REST and NETCONF APIs, and the expectation is that operational workflows — provisioning, compliance checking, telemetry — are programmatic. A network engineer who cannot read a Python script or understand a JSON payload is already operating at a disadvantage on day one of most modern network engineering jobs.


Data Formats — JSON, XML, YAML, and YANG

Before you can work with any API, you need to understand how data is serialized — how structured information is encoded as text for transmission between systems. Three formats dominate network automation: JSON, XML, and YAML. They serve different purposes and appear in different contexts, so you need to be comfortable reading all three.

JSON

JSON (JavaScript Object Notation) is the lingua franca of modern REST APIs. Despite the “JavaScript” in the name, it is completely language-agnostic and supported natively in Python, Go, Ruby, and virtually every other language with a standard library. The format is minimal: data lives in key-value pairs enclosed in curly braces, arrays are delimited by square brackets, and the supported scalar types are string (double-quoted), number (unquoted), boolean (true/false), and null.

Here is what a JSON response from a network device API might look like — the programmatic equivalent of show ip interface brief:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
  "ietf-interfaces:interfaces": {
    "interface": [
      {
        "name": "GigabitEthernet0/0/1",
        "description": "Uplink to Core-01",
        "enabled": true,
        "ietf-ip:ipv4": {
          "address": [
            {
              "ip": "10.10.1.2",
              "prefix-length": 30
            }
          ]
        },
        "oper-status": "up"
      },
      {
        "name": "GigabitEthernet0/0/2",
        "description": "Link to Access-12",
        "enabled": true,
        "ietf-ip:ipv4": {
          "address": [
            {
              "ip": "10.10.2.1",
              "prefix-length": 30
            }
          ]
        },
        "oper-status": "up"
      },
      {
        "name": "Loopback0",
        "description": "Router-ID",
        "enabled": true,
        "ietf-ip:ipv4": {
          "address": [
            {
              "ip": "10.255.255.1",
              "prefix-length": 32
            }
          ]
        },
        "oper-status": "up"
      }
    ]
  }
}

Notice the structure: a top-level object with a single key that contains another object with an “interface” key whose value is an array of interface objects. Each interface object has predictable keys — name, description, enabled, the IP address nested inside ietf-ip:ipv4. This predictability is what makes JSON easy to process programmatically. You can write code that reaches directly into data["ietf-interfaces:interfaces"]["interface"][0]["name"] without parsing a string.

In Python, the standard library json module handles encoding and decoding. Pretty-printing a JSON structure for inspection is a single call:

1
2
3
4
5
6
7
import json

# pretty-print with 2-space indentation
print(json.dumps(data, indent=2))

# parse a JSON string back into a Python dict
parsed = json.loads(response_text)

XML

XML (Extensible Markup Language) is the older format, dating back to the late 1990s, and it is still heavily used in network automation — specifically in NETCONF, which we will cover in the API section. Where JSON uses curly braces and brackets, XML wraps every piece of data in matching opening and closing tags.

The same interface information above in XML looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces">
  <interface>
    <name>GigabitEthernet0/0/1</name>
    <description>Uplink to Core-01</description>
    <enabled>true</enabled>
    <ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip">
      <address>
        <ip>10.10.1.2</ip>
        <prefix-length>30</prefix-length>
      </address>
    </ipv4>
  </interface>
</interfaces>

XML is more verbose than JSON and harder to read at a glance, but it has features JSON lacks — namespaces (the xmlns= attributes above), attributes within tags, comments, and a mature schema language (XSD). Legacy Cisco platforms and the NETCONF protocol use XML because NETCONF predates the widespread adoption of JSON. Python’s xml.etree.ElementTree module or the third-party lxml library handles parsing.

YAML

YAML (YAML Ain’t Markup Language) is not typically used as an API wire format — you will not see a Cisco device returning YAML payloads. Instead, YAML is the dominant format for human-written configuration files. Ansible playbooks are YAML. Kubernetes manifests are YAML. Docker Compose files are YAML. It was designed to be the most human-readable option: no braces, no quotes for most strings, indentation indicates nesting.

A critical trap for new YAML writers: tabs are forbidden. YAML uses spaces for indentation and will throw a parse error if you mix in a tab character. Every YAML-aware editor should have “convert tabs to spaces” enabled.

The same data represented in YAML:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
interfaces:
  - name: GigabitEthernet0/0/1
    description: Uplink to Core-01
    enabled: true
    ipv4:
      address: 10.10.1.2
      prefix_length: 30
  - name: GigabitEthernet0/0/2
    description: Link to Access-12
    enabled: true
    ipv4:
      address: 10.10.2.1
      prefix_length: 30

JSON and YAML are closely related — valid JSON is valid YAML (the YAML 1.2 spec explicitly defines this relationship), and Python’s PyYAML library can read either. In practice you use JSON when talking to APIs and YAML when writing configuration files that humans need to read and edit.

YANG

One more format worth a brief mention: YANG is not a data serialization format. It is a data modeling language — it describes the shape that data should have. Think of it as a schema. A YANG model says “an interface has a name (string), an enabled state (boolean), and an optional IPv4 address block that contains an IP (string) and prefix-length (uint8).” The JSON and XML you actually send over the wire are validated against the YANG model. Cisco IOS-XE ships with a large set of vendor-specific and standards-based YANG models that define every configurable parameter. The pyang tool lets you browse and validate YANG models from the command line. For the CCNA exam, understanding that YANG is the schema layer that sits behind NETCONF and RESTCONF is sufficient.

Data Format Comparison

Feature JSON XML YAML
Primary use REST API payloads NETCONF, legacy APIs Human config files (Ansible, K8s)
Readability Good Poor (verbose) Excellent
Python parse library json (stdlib) xml.etree, lxml PyYAML, ruamel.yaml
Supports comments No Yes Yes
Whitespace significant No No Yes (indentation)
Schema language JSON Schema XSD / YANG (none standard)
Wire format in Cisco APIs RESTCONF NETCONF No

APIs — REST, RESTCONF, NETCONF, and Beyond

An API (Application Programming Interface) is a defined contract for how one piece of software talks to another. In a network context, an API replaces the human at the keyboard: instead of a person reading terminal output and typing commands, a program sends structured requests and receives structured responses. This distinction — programmatic interaction versus screen-scraping — is what makes APIs foundational to modern automation.

REST Principles

REST (Representational State Transfer) is an architectural style rather than a protocol. A REST API uses standard HTTP methods to perform operations on resources identified by URLs. The key constraint is that it is stateless: each request carries all the information needed to fulfill it, and the server keeps no session state between requests.

The HTTP verbs map cleanly to CRUD operations:

  • GET — retrieve a resource (read-only, safe)
  • POST — create a new resource
  • PUT — replace a resource entirely
  • PATCH — modify specific fields of a resource
  • DELETE — remove a resource

A URL like https://router1.lab.local/restconf/data/ietf-interfaces:interfaces/interface=GigabitEthernet0%2F0%2F1 is a resource identifier. A GET to that URL retrieves the current state of that interface. A PATCH to the same URL with a JSON body updates specific fields. The URL structure is self-describing, which means well-designed REST APIs are navigable without a thick reference manual.

HTTP status codes are how the server communicates the outcome of your request:

Code Meaning Common cause
200 OK Request succeeded Successful GET, PUT, PATCH
201 Created Resource created Successful POST
204 No Content Success, no body returned Successful DELETE
400 Bad Request Malformed request Invalid JSON, missing required field
401 Unauthorized Authentication required Missing or invalid token
403 Forbidden Authenticated but not allowed Insufficient permissions
404 Not Found Resource does not exist Wrong URL, wrong resource name
409 Conflict State conflict Duplicate resource creation
500 Internal Server Error Server-side failure Bug in API implementation

Authentication is always required. The three common patterns you will encounter in Cisco APIs:

# API Key in a custom header
X-Auth-Token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

# Bearer token (OAuth 2.0 style)
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

# HTTP Basic Auth (base64-encoded user:password)
Authorization: Basic Y2lzY286Q2lzY28xMjM=

Catalyst Center uses a two-step flow: first POST to /api/system/v1/auth/token with Basic Auth to get a token, then use that token as X-Auth-Token on all subsequent requests.

A Real RESTCONF Example

RESTCONF (RFC 8040) is a REST API layered on top of YANG models. It is available on Cisco IOS-XE 16.6+ and provides a clean HTTP interface to the same data that NETCONF accesses. The base path is always /restconf/data/.

Querying an interface via curl:

1
2
3
4
curl -k -s \
  -H "Accept: application/yang-data+json" \
  -u admin:Cisco123 \
  "https://10.10.10.1/restconf/data/ietf-interfaces:interfaces/interface=GigabitEthernet0%2F0%2F1"

The -k flag skips certificate validation (acceptable in a lab; never in production). The Accept header tells the device to return JSON rather than XML. Note the URL-encoding of the forward slash in the interface name: / becomes %2F.

The same request in Python using the requests library:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import requests
import json
import urllib3

# Suppress certificate warnings in a lab environment
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

BASE_URL = "https://10.10.10.1"
HEADERS = {
    "Accept": "application/yang-data+json",
    "Content-Type": "application/yang-data+json",
}

session = requests.Session()
session.auth = ("admin", "Cisco123")
session.verify = False
session.headers.update(HEADERS)

# GET a specific interface
url = f"{BASE_URL}/restconf/data/ietf-interfaces:interfaces/interface=GigabitEthernet0%2F0%2F1"
response = session.get(url)

if response.status_code == 200:
    data = response.json()
    print(json.dumps(data, indent=2))
else:
    print(f"Error {response.status_code}: {response.text}")

Configuring an interface description via PATCH:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
payload = {
    "ietf-interfaces:interface": {
        "name": "GigabitEthernet0/0/1",
        "description": "Uplink to Core-01 -- automated"
    }
}

url = f"{BASE_URL}/restconf/data/ietf-interfaces:interfaces/interface=GigabitEthernet0%2F0%2F1"
response = session.patch(url, json=payload)
print(response.status_code)  # 204 on success

NETCONF

NETCONF (RFC 6241) is an SSH-based protocol that predates REST APIs in the network world. It uses XML payloads and defines a set of operations: get-config retrieves configuration, edit-config modifies it, commit applies changes (on devices with a candidate datastore), and lock/unlock prevent concurrent modification. Unlike RESTCONF which is stateless, NETCONF runs over a persistent SSH session with maintained state.

NETCONF is the protocol Catalyst Center uses to talk to managed devices southbound. For direct scripting, the ncclient Python library provides NETCONF support. For the CCNA exam you do not need to write NETCONF XML by hand, but you should know that NETCONF exists, that it uses XML, and that it is the protocol behind Cisco’s device APIs at the platform level.

gNMI and gRPC

The generation after NETCONF is gNMI (gRPC Network Management Interface), which uses gRPC (Google’s remote procedure call framework) over HTTP/2. The critical feature gNMI adds is streaming telemetry: instead of polling a device every 60 seconds to check interface counters, you subscribe once and the device pushes updates to you whenever values change. This enables near-real-time network observability. gNMI is beyond the CCNA scope but worth knowing exists as the direction the industry is heading.

Protocol Comparison

+------------------+----------+-----------+-----------+-------------+
| Feature          | CLI/SSH  | NETCONF   | RESTCONF  | gNMI/gRPC   |
+------------------+----------+-----------+-----------+-------------+
| Transport        | SSH      | SSH       | HTTPS     | HTTP/2      |
| Data format      | Text     | XML       | JSON/XML  | Protobuf    |
| Data model       | None     | YANG      | YANG      | YANG        |
| Structured data  | No       | Yes       | Yes       | Yes         |
| Streaming push   | No       | No        | No        | Yes         |
| Transactions     | No       | Yes       | No        | No          |
| Human-readable   | Yes      | No        | Somewhat  | No          |
| Cisco IOS-XE     | Always   | 16.6+     | 16.6+     | 16.12+      |
+------------------+----------+-----------+-----------+-------------+

Python for Network Automation

Python became the dominant language in network automation for straightforward reasons: readable syntax that non-programmers can follow, an enormous ecosystem of third-party libraries, and a culture that grew up inside the network engineering community with tools purpose-built for this domain. If you are going to learn one programming language for networking, Python is the unambiguous choice.

netmiko

netmiko (Network Multi-vendor Support) is a Python library built by Kirk Byers that wraps Paramiko (a lower-level SSH library) and adds network device-specific handling. It knows how to deal with the quirks of different vendor SSH implementations — the fact that Cisco ASA prompts differently than IOS, that NX-OS paginates output differently, that some devices need extra time between commands. As of 2026 the current release is netmiko 4.7.0.

Install it:

1
pip install netmiko

A complete example that connects to a Cisco IOS device, gathers show command output, and pushes a configuration change:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException

# Device dictionary — the core of every netmiko connection
device = {
    "device_type": "cisco_ios",
    "host": "10.10.10.5",
    "username": "netadmin",
    "password": "SuperSecret99",
    "secret": "EnableSecret99",   # enable password, if needed
    "port": 22,
    "timeout": 15,
}

try:
    # ConnectHandler automatically handles login and enable mode
    with ConnectHandler(**device) as conn:
        conn.enable()  # enter privileged exec mode

        # --- Show commands ---
        output = conn.send_command("show ip interface brief")
        print(output)

        # send_command with TextFSM parsing (returns a list of dicts)
        neighbors = conn.send_command(
            "show cdp neighbors detail",
            use_textfsm=True
        )
        for neighbor in neighbors:
            print(f"  {neighbor['destination_host']} via {neighbor['local_port']}")

        # --- Configuration changes ---
        config_commands = [
            "interface GigabitEthernet0/1",
            "description ** automated via netmiko **",
            "no shutdown",
        ]
        result = conn.send_config_set(config_commands)
        print(result)

        # Save the configuration
        conn.save_config()

except NetmikoTimeoutException:
    print(f"Connection to {device['host']} timed out")
except NetmikoAuthenticationException:
    print(f"Authentication failed for {device['host']}")

Key device_type values you need to know:

Platform device_type
Cisco IOS (traditional) cisco_ios
Cisco IOS-XE cisco_xe
Cisco NX-OS cisco_nxos
Cisco ASA cisco_asa
Cisco IOS-XR cisco_xr
Arista EOS arista_eos
Juniper JunOS juniper_junos
Palo Alto PAN-OS paloalto_panos

send_command() is for show commands — it waits for the device prompt to return before handing you the output. send_config_set() takes a list of commands and handles the configure terminal / end wrapping automatically. The with statement (context manager) ensures the SSH connection is cleanly closed even if an exception occurs.

The use_textfsm=True argument to send_command() is worth highlighting. TextFSM is a template engine that parses CLI text output into structured data — it is how you get a list of Python dictionaries out of something like show ip ospf neighbor, rather than a blob of text you have to parse yourself. The ntc-templates project provides a large library of pre-built TextFSM templates for Cisco, Arista, Juniper, and others. netmiko bundles these templates and integrates them directly, so you get structured data for many common show commands with a single argument.

NAPALM

NAPALM (Network Automation and Programmability Abstraction Layer with Multivendor support) sits a layer above netmiko. Where netmiko gives you SSH access and lets you send arbitrary commands, NAPALM provides getter functions that return structured Python dictionaries using a vendor-neutral schema. The get_interfaces() function returns the same dictionary structure whether the device underneath is a Cisco IOS router, an Arista switch, or a Juniper firewall. As of 2026 the current release is NAPALM 5.x.

Install it:

1
pip install napalm

Basic getter usage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from napalm import get_network_driver

# Select the driver for your platform
driver = get_network_driver("ios")  # ios, eos, junos, nxos_ssh, iosxr

device = driver(
    hostname="10.10.10.5",
    username="netadmin",
    password="SuperSecret99",
    optional_args={"secret": "EnableSecret99"}
)

device.open()

# Getters return consistent Python dicts regardless of vendor
interfaces = device.get_interfaces()
print(interfaces["GigabitEthernet0/1"])
# {
#   'is_up': True,
#   'is_enabled': True,
#   'description': 'Uplink to Core-01',
#   'speed': 1000,
#   'mtu': 1500,
#   'mac_address': 'AA:BB:CC:DD:EE:01',
#   'last_flapped': 1234567.89
# }

bgp = device.get_bgp_neighbors()
for neighbor_ip, details in bgp.get("global", {}).get("peers", {}).items():
    state = "up" if details["is_up"] else "DOWN"
    print(f"  BGP {neighbor_ip}: {state}, prefixes received: {details['address_family']['ipv4']['received_prefixes']}")

device.close()

The configuration management workflow is where NAPALM really shines. It provides a diff-then-commit pattern similar to how you would review an IaC plan before applying it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from napalm import get_network_driver

driver = get_network_driver("ios")
device = driver("10.10.10.5", "netadmin", "SuperSecret99",
                optional_args={"secret": "EnableSecret99"})
device.open()

# Stage the desired configuration (full config or merge candidate)
device.load_merge_candidate(filename="intended_config.txt")

# See what would change — this is the diff
diff = device.compare_config()
if diff:
    print("Proposed changes:")
    print(diff)

    confirm = input("Apply changes? [y/N]: ")
    if confirm.lower() == "y":
        device.commit_config()
        print("Configuration committed.")
    else:
        device.discard_config()
        print("Changes discarded.")
else:
    print("Device is already in desired state.")

device.close()

The compare_config() method returns a unified diff — lines prefixed with - are being removed, lines with + are being added. This is a crucial safety mechanism: you can review exactly what will change before touching production.

When to Use What

Scenario Recommended tool
Quick ad-hoc show commands, single vendor netmiko
Config push to a specific Cisco device netmiko send_config_set()
Structured data needed (BGP state, interface stats) NAPALM getters
Multi-vendor environment with consistent schema NAPALM
Config diff and safe commit workflow NAPALM load_merge_candidate()
Large-scale orchestration (100+ devices) nornir + netmiko/NAPALM

A brief note on nornir: it is a pure-Python automation framework that provides threading, inventory management, and task orchestration without requiring YAML playbooks. If you find Ansible’s YAML syntax limiting and prefer to express complex logic in Python, nornir is the alternative. It uses netmiko and NAPALM as connection backends while giving you full Python control flow. It is beyond the CCNA scope but worth knowing exists.


Ansible for Network Devices

Ansible is an IT automation tool that has become standard in both server management and network automation. It is agentless — there is nothing to install on the managed devices — it uses YAML playbooks to describe desired state, and it communicates over SSH (or via REST APIs for newer network platforms). The current version family is ansible-core 2.20.x, packaged as the ansible distribution which bundles community collections.

Why Agentless Matters for Network Devices

Managed servers can run a Python agent. Your Cisco IOS switch cannot — it runs IOS, not Linux, and you cannot install arbitrary software on it. Ansible’s agentless design means it handles all the intelligence on the control node (your laptop or automation server), connecting to devices over SSH and translating YAML tasks into CLI commands or API calls. The network device just has to be reachable over SSH with valid credentials.

Inventory

Ansible’s inventory describes what you are managing and how to connect to it. A simple INI-style inventory grouping devices by role:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# inventory/hosts.ini

[core_routers]
core-01 ansible_host=10.10.1.1
core-02 ansible_host=10.10.1.2

[distribution_switches]
dist-01 ansible_host=10.10.2.1
dist-02 ansible_host=10.10.2.2

[access_switches]
access-01 ansible_host=10.10.3.1
access-02 ansible_host=10.10.3.2
access-03 ansible_host=10.10.3.3

[cisco_ios:children]
core_routers
distribution_switches
access_switches

[cisco_ios:vars]
ansible_network_os=cisco.ios.ios
ansible_connection=network_cli
ansible_user=netadmin
ansible_password="{{ vault_net_password }}"
ansible_become=yes
ansible_become_method=enable
ansible_become_password="{{ vault_enable_password }}"

The ansible_connection=network_cli variable is critical — it tells Ansible to use the network CLI connection plugin rather than the default SSH/Python agent approach. Without it, Ansible would try to transfer a Python script to the device and execute it, which would fail. The ansible_network_os variable tells the connection plugin which CLI implementation to use for things like prompt recognition and terminal width handling.

Passwords are wrapped in {{ }} because they should come from Ansible Vault, not hardcoded in a file that will live in version control.

A Complete Playbook

This playbook gathers facts from all IOS devices and then pushes a VLAN configuration to access switches:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# playbook_vlans.yml
---
- name: Gather facts and configure VLANs on access switches
  hosts: cisco_ios
  gather_facts: false

  tasks:
    - name: Gather IOS facts
      cisco.ios.ios_facts:
        gather_subset:
          - "!all"
          - "!min"
          - interfaces

    - name: Print device hostname and IOS version
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} is running {{ ansible_net_version }}"

- name: Configure VLANs on access switches
  hosts: access_switches
  gather_facts: false

  vars:
    vlans_to_configure:
      - vlan_id: 10
        name: CORPORATE_DATA
      - vlan_id: 20
        name: VOICE
      - vlan_id: 200
        name: GUEST_WIFI

  tasks:
    - name: Configure VLANs using resource module
      cisco.ios.ios_vlans:
        config:
          - vlan_id: "{{ item.vlan_id }}"
            name: "{{ item.name }}"
            state: active
        state: merged
      loop: "{{ vlans_to_configure }}"
      notify: Save configuration

    - name: Verify VLANs were created
      cisco.ios.ios_command:
        commands:
          - show vlan brief
      register: vlan_output

    - name: Display VLAN verification output
      ansible.builtin.debug:
        var: vlan_output.stdout_lines

  handlers:
    - name: Save configuration
      cisco.ios.ios_command:
        commands:
          - write memory

Walk through the key constructs:

hosts: — which inventory group this play targets. Ansible parallelizes by default across all matching hosts, so all access switches get configured simultaneously.

gather_facts: false — network devices do not support Ansible’s default fact-gathering mechanism. You explicitly call ios_facts when you need device data.

cisco.ios.ios_vlans — a resource module. Resource modules understand the current state of the device and only make changes that are needed. The state: merged means “ensure these VLANs exist; leave everything else alone.” The alternative states are replaced (this list is the complete desired state for VLANs), overridden (delete any VLANs not in this list), and deleted (remove specified VLANs). This is idempotency in practice: run the playbook on a switch that already has VLAN 10 correctly configured and Ansible makes no changes to that VLAN.

loop: — iterates the task over each item in the list. Each iteration configures one VLAN.

register: — captures the output of a task into a variable for later use.

notify: and handlers — a handler runs at the end of the play if it was notified. Using a handler for “save configuration” means the config is saved once at the end of a play, not after every individual change — exactly the right behavior.

Key Ansible Modules for Cisco IOS

Module Purpose
cisco.ios.ios_command Run arbitrary show commands, capture output
cisco.ios.ios_config Push raw config lines (not idempotent)
cisco.ios.ios_vlans Resource module for VLAN management (idempotent)
cisco.ios.ios_interfaces Resource module for interface configuration
cisco.ios.ios_l3_interfaces IP address configuration
cisco.ios.ios_ospf_interfaces OSPF interface parameters
cisco.ios.ios_facts Gather structured device facts
cisco.ios.ios_banner Configure login/MOTD banners

The distinction between ios_config (raw lines) and the resource modules (ios_vlans, ios_interfaces, etc.) matters. ios_config is a blunt instrument — it pushes lines into config mode and has limited ability to determine whether the device is already in the desired state. Resource modules are declarative and idempotent. Prefer resource modules where they exist.

When Ansible Is Not the Answer

Ansible is a powerful tool for configuration management but it has real limitations. It is not designed for real-time operations — if you need to react to a BGP flap within milliseconds, Ansible is the wrong tool. Complex conditional logic (if BGP neighbor X is down, fail over to Y, but only if Z has fewer than N routes in its table) becomes painful in YAML and much cleaner in Python. Ansible also has a learning curve for debugging — when a task fails, the error messages can be cryptic, and understanding what actually happened requires knowing how the underlying modules work. For anything beyond standard configuration management, Python with netmiko or NAPALM gives you more control.


Cisco DNA Center / Catalyst Center

In 2023, Cisco renamed DNA Center to Catalyst Center. The product is the same — the branding change reflects Cisco’s broader portfolio realignment around the “Catalyst” family name. You will still see “DNAC” in API paths, documentation, and real-world deployments, so understanding that the two names refer to the same platform is practically important.

Catalyst Center is Cisco’s intent-based networking platform for the enterprise campus and branch. At its core it is a controller: a centralized system that maintains a model of what the network should look like and continuously works to bring the actual network into alignment with that model. It is deployed as an appliance (physical or virtual) and manages Cisco Catalyst switches, routers, and wireless infrastructure via its southbound interfaces.

What Catalyst Center Does

Network Discovery and Inventory: Catalyst Center uses SNMP, SSH, and NETCONF to discover devices on the network, pull their configurations, and build a topology model. The inventory shows every managed device, its software version, and its health state. This is valuable on its own — many organizations struggle to maintain accurate network inventory.

Software Image Management (SWIM): Catalyst Center manages the IOS/IOS-XE image lifecycle. It knows what images are available, which devices are running outdated or vulnerable versions, and can orchestrate image upgrades across the fleet with pre- and post-validation checks. Upgrading 200 switches one at a time via TFTP and archive download-sw is a multi-week project. With SWIM it is a scheduled job.

Policy-Based Segmentation (via SDA): This is where Catalyst Center moves beyond traditional VLAN-based segmentation. Software-Defined Access (SDA) creates a network fabric using VXLAN as the data plane and LISP as the control plane. Virtual networks (VNs) replace VLANs as the segmentation boundary, and Scalable Group Tags (SGTs) provide policy enforcement at the endpoint identity level rather than the port or VLAN level. You define a policy: “Finance workstations can reach the accounting server but not the guest network.” Catalyst Center translates that intent into the appropriate VXLAN VNI assignments, LISP database entries, and TrustSec SGT mappings across every device in the fabric.

Assurance: The assurance capability ingests telemetry from managed devices (using streaming telemetry, SNMP, and API polling), calculates health scores for clients, APs, switches, and routers, and applies ML models to identify root causes of issues. When a client is experiencing poor connectivity, Assurance can correlate RF data, DHCP logs, AAA events, and interface counters to identify whether the problem is the AP channel, the uplink switch, the DHCP server, or something else. This shifts network operations from reactive (someone calls the help desk) to proactive (the system identifies an issue before users notice).

The Northbound API

Every automation story about Catalyst Center eventually leads to its northbound REST API. This is the interface you use when you want to integrate Catalyst Center with other enterprise systems — your ITSM tool (ServiceNow), your monitoring stack (Splunk, Grafana), or your own custom automation scripts.

Authentication always starts with obtaining a token:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import requests
import base64

CATALYST_CENTER = "https://catalyst-center.lab.local"

def get_token(host, username, password):
    url = f"{host}/api/system/v1/auth/token"
    credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
    headers = {
        "Authorization": f"Basic {credentials}",
        "Content-Type": "application/json",
    }
    response = requests.post(url, headers=headers, verify=False)
    response.raise_for_status()
    return response.json()["Token"]

token = get_token(CATALYST_CENTER, "admin", "Cisco123!")

With the token, all subsequent requests use the X-Auth-Token header:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
headers = {
    "X-Auth-Token": token,
    "Content-Type": "application/json",
}

# Get all network devices
response = requests.get(
    f"{CATALYST_CENTER}/api/v1/network-device",
    headers=headers,
    verify=False,
)
devices = response.json()["response"]
for device in devices:
    print(f"{device['hostname']:30s} {device['softwareVersion']:15s} {device['reachabilityStatus']}")

The Catalyst Center API is organized into intent API endpoints under /api/v1/ (and newer versioned paths). Key endpoint families include:

  • /api/v1/network-device — inventory, device health, interface details
  • /api/v1/topology — physical and logical topology data
  • /api/v1/client-detail — client health and connection details
  • /api/v1/template-programmer/template — configuration templates
  • /api/v1/task — status tracking for async operations (most configuration changes return a task ID rather than immediate results)

The async task pattern is important to understand. When you trigger an operation like a software image upgrade, Catalyst Center returns a 202 Accepted response with a task ID in the body. You then poll /api/v1/task/{task_id} until the task transitions to SUCCESS or FAILURE. This is standard practice for any long-running operation.

Intent-Based Networking Concepts

The phrase “intent-based networking” sounds like marketing, and to some extent it is, but it describes a real architectural shift. Traditional network management is imperative: you specify exactly how to achieve a goal, one command at a time, device by device. Intent-based networking is declarative: you specify what the network should do and the controller figures out how.

The contrast becomes concrete with segmentation. In a traditional network, isolating the GUEST_WIFI VLAN from the CORP network means configuring inter-VLAN routing ACLs on the distribution switches, port-based VLAN assignments on every access port, and DMZ firewall rules. Each of these is a separate configuration task on separate devices, and consistency requires that every engineer who ever touches those devices understands the original intent and respects it. In an SDA fabric managed by Catalyst Center, you define the policy once at the controller (“GUEST VN has no access to CORP VN”), and the controller handles the translation to VXLAN VNIs, LISP EIDs, and SGT policies across the entire fabric. New devices added to the fabric automatically receive the correct policies. An access switch swapped for a replacement gets its policy configuration pushed automatically during onboarding.

For the CCNA exam, the key concepts to internalize are:

  • Catalyst Center sits above the network as a controller (not in the data path)
  • It manages devices southbound via NETCONF, SSH, and SNMP
  • It exposes a REST API northbound for integration
  • Intent-based networking separates policy definition (what you want) from policy implementation (how devices are configured)
  • SDA uses VXLAN + LISP as the fabric data and control planes, with SGTs for group-based policy

Configuration Management and Version Control

A Python script that automates configuration changes is a significant improvement over manual CLI work. A Python script that applies changes from files stored in a Git repository, with peer review and an audit trail, is an infrastructure-grade automation system.

The core idea is to treat network configuration as code: store it in version control, apply changes through a controlled process, and maintain a record of every modification with metadata (who, when, why). The benefits are direct:

Audit trail: Git commit history shows every configuration change with a timestamp, an author, and a commit message explaining the reason. Six months from now, when someone asks why interface Gi0/0/3 has that ACL applied, the answer is in the repository.

Revert capability: If a change causes a problem, git revert gets you back to the previous state. You push the reverted config with the same automation tooling that applied the original change.

Peer review: Configuration changes proposed via pull requests can be reviewed by a colleague before they are applied. This catches mistakes that authors miss — a second pair of eyes on an access list change or a routing policy modification before it touches production.

Drift detection: Periodically run a job that uses NAPALM’s compare_config() to diff the running configuration on each device against the version in the repository. Any deviations are drift — configuration that exists on the device but is not tracked, which is a security and compliance problem.

Network Source of Truth

Beyond device configs, you need a database that tracks what should exist in your network. This is the “source of truth” concept: an authoritative record of every device (name, IP, role, location), every VLAN (ID, name, VRF), every prefix (owner, allocation), every cable connection. Two popular open-source platforms for this are NetBox (maintained by the NetBox Labs community) and Nautobot (a NetBox fork with stronger plugin architecture and automation integration, maintained by Network to Code). Both expose REST APIs that Ansible and Python scripts can query.

The workflow looks like this:

+------------------+       query        +------------------+
|  Source of Truth |  <-----------      |  Ansible / Python|
|  (NetBox /       |                    |  (control node)  |
|   Nautobot)      |  return data  -->  |                  |
+------------------+                    +--------+---------+
                                                  |
                                           SSH / NETCONF
                                                  |
                                    +-------------+-------------+
                                    |             |             |
                              +-----+----+   +----+-----+  +---+------+
                              | Router-1 |   | Switch-1 |  | Switch-2 |
                              +----------+   +----------+  +----------+

The full automation stack, from intent to wire, looks like this:

  +-------------------------------------+
  |         Business Intent             |
  |  "Sales VLAN can reach internet"    |
  +---------------+---------------------+
                  |
                  v
  +-------------------------------------+
  |        Catalyst Center              |
  |     (controller / orchestrator)     |
  +---------------+---------------------+
                  |  Translates intent to config
                  v
  +-------------------------------------+
  |     Source of Truth (NetBox)        |
  |  Devices, VLANs, prefixes, roles    |
  +---------------+---------------------+
                  |  Variables / inventory
                  v
  +-------------------------------------+
  |    Automation Layer                 |
  |    Ansible playbooks                |
  |    Python / netmiko / NAPALM        |
  +---------------+---------------------+
                  |  SSH / NETCONF / RESTCONF
                  v
  +---------------------------------------------+
  |  Network Devices                            |
  |  IOS-XE routers, Catalyst switches, APs     |
  +---------------------------------------------+

Practical Project — Tying It Together

Theory becomes durable when you build something. Here is a complete practical project: a Python script that uses netmiko to collect OSPF neighbor state from multiple routers, parses the output into structured data, and generates an alert if any neighbor is not in Full state.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
#!/usr/bin/env python3
"""
ospf_neighbor_check.py

Polls OSPF neighbor state from a list of routers using netmiko.
Parses 'show ip ospf neighbor' output with TextFSM.
Writes results to JSON. Prints alert for non-Full neighbors.

Requires: netmiko 4.7+, pip install netmiko
"""

import json
import sys
from datetime import datetime
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException

# --- Device inventory (in production, load from NetBox API or YAML file) ---
ROUTERS = [
    {"hostname": "core-01", "host": "10.10.1.1"},
    {"hostname": "core-02", "host": "10.10.1.2"},
    {"hostname": "dist-01", "host": "10.10.2.1"},
    {"hostname": "dist-02", "host": "10.10.2.2"},
]

# Shared credentials (in production, use Ansible Vault or HashiCorp Vault)
CREDENTIALS = {
    "device_type": "cisco_ios",
    "username": "netadmin",
    "password": "SuperSecret99",
    "secret": "EnableSecret99",
    "timeout": 15,
}


def get_ospf_neighbors(hostname, host):
    """
    Connect to a device and return a list of OSPF neighbor dicts.
    Returns an empty list on connection failure (with a logged error).
    """
    device_params = {**CREDENTIALS, "host": host}

    try:
        with ConnectHandler(**device_params) as conn:
            conn.enable()

            # use_textfsm=True requires ntc-templates package
            # Returns a list of dicts with keys:
            # neighbor_id, priority, state, dead_time, address, interface
            neighbors = conn.send_command(
                "show ip ospf neighbor",
                use_textfsm=True,
            )

            # send_command returns a string if TextFSM fails to parse
            if isinstance(neighbors, str):
                print(f"  WARNING: TextFSM parse failed for {hostname}, raw output:")
                print(f"  {neighbors[:200]}")
                return []

            return neighbors

    except NetmikoTimeoutException:
        print(f"  ERROR: Connection timeout to {hostname} ({host})")
        return []
    except NetmikoAuthenticationException:
        print(f"  ERROR: Authentication failure on {hostname} ({host})")
        return []
    except Exception as e:
        print(f"  ERROR: Unexpected error on {hostname} ({host}): {e}")
        return []


def check_ospf_state():
    """
    Main function: iterate routers, gather OSPF neighbor data,
    identify problems, write JSON report.
    """
    results = {}
    problems = []
    timestamp = datetime.utcnow().isoformat() + "Z"

    print(f"OSPF Neighbor Check — {timestamp}")
    print("=" * 60)

    for router in ROUTERS:
        hostname = router["hostname"]
        host = router["host"]

        print(f"\nChecking {hostname} ({host})...")
        neighbors = get_ospf_neighbors(hostname, host)

        results[hostname] = {
            "host": host,
            "neighbor_count": len(neighbors),
            "neighbors": neighbors,
            "checked_at": timestamp,
        }

        if not neighbors:
            problems.append({
                "device": hostname,
                "issue": "No OSPF neighbors found or connection failed",
                "severity": "CRITICAL",
            })
            print(f"  CRITICAL: No neighbors returned")
            continue

        # Evaluate each neighbor
        for nbr in neighbors:
            nbr_id = nbr.get("neighbor_id", "unknown")
            state = nbr.get("state", "").upper()
            interface = nbr.get("interface", "unknown")

            # OSPF Full = converged adjacency. 2WAY = DR/BDR relationship on broadcast
            # segments. Anything else (INIT, EXSTART, EXCHANGE, LOADING) = problem.
            if "FULL" in state or "2WAY" in state:
                print(f"  OK: Neighbor {nbr_id} on {interface} — state: {state}")
            else:
                problem = {
                    "device": hostname,
                    "neighbor_id": nbr_id,
                    "interface": interface,
                    "state": state,
                    "issue": f"OSPF neighbor not in Full/2Way state: {state}",
                    "severity": "WARNING",
                }
                problems.append(problem)
                print(f"  WARNING: Neighbor {nbr_id} on {interface} — state: {state}")

    # Write JSON report
    report = {
        "timestamp": timestamp,
        "problems": problems,
        "devices": results,
    }
    report_file = "ospf_neighbor_report.json"
    with open(report_file, "w") as f:
        json.dump(report, f, indent=2)
    print(f"\nReport written to {report_file}")

    # Summary
    print("\n" + "=" * 60)
    if problems:
        print(f"ALERT: {len(problems)} problem(s) detected:")
        for p in problems:
            print(f"  [{p['severity']}] {p['device']}: {p['issue']}")
        return 1  # Non-zero exit code for monitoring systems to detect
    else:
        print("All OSPF neighbors are in healthy state.")
        return 0


if __name__ == "__main__":
    sys.exit(check_ospf_state())

Walk through the design decisions:

The device inventory is a plain list of dicts. In a real environment you would pull this from NetBox via its REST API (requests.get("https://netbox.company.com/api/dcim/devices/?role=router")), which ensures your script always reflects the current network topology rather than a hardcoded list.

Credentials use dictionary unpacking ({**CREDENTIALS, "host": host}). This keeps the shared credential block separate from the per-device specifics and makes it easy to swap in vault-retrieved credentials.

The TextFSM fallback — checking isinstance(neighbors, str) — handles the case where the device output does not match the template. netmiko returns the raw string in that case rather than raising an exception, so you need to check explicitly.

Exit code 1 on problems lets monitoring systems detect failures without parsing output. A cron job or CI/CD pipeline can treat a non-zero exit as an alert.

The JSON report provides machine-readable output for downstream processing — feeding into a monitoring dashboard, creating a ServiceNow incident ticket, or archiving for trend analysis. Writing structured data instead of just printing human-readable text is a habit worth building from the start.

To run this in production you would:

  1. Store credentials in Ansible Vault or HashiCorp Vault and load them at runtime
  2. Schedule via cron or a job scheduler (Jenkins, Airflow)
  3. Add email or Slack alerting by calling an API in the if problems: block
  4. Pull the device list dynamically from NetBox

Putting It All Together

The shift from CLI-only network engineering to programmable infrastructure is not a single leap — it is a ramp that starts exactly where you are now. The CCNA automation domain asks you to understand the concepts and be able to reason about them, not to arrive at the exam having already built a full NetBox-driven automation pipeline.

The practical progression looks like this: start with netmiko, because it lets you automate without leaving the SSH-and-show-commands world you already know. Write scripts that collect data from your lab switches, parse it, and output structured reports. Add NAPALM when you want vendor-neutral structured data return. Add Ansible when you want declarative configuration management with idempotency and role-based organization. Understand Catalyst Center as the controller layer above all of this, the system that manages the southbound complexity so that humans only need to reason about intent.

The underlying thread across all of it — JSON, REST APIs, Python, Ansible YAML, Catalyst Center’s northbound API — is this: structured data and defined interfaces are what make automation possible. CLI text is unstructured and human-oriented. JSON is structured and machine-oriented. Every tool in this stack is essentially bridging the gap between the two: converting CLI text to structured data, or converting human-written YAML to structured API calls, or converting business policy to device configuration.

Get comfortable reading JSON. Get comfortable writing simple Python. Understand what Ansible playbooks express at a high level. Know what Catalyst Center does and why it exists. That combination will carry you through the CCNA exam and give you the vocabulary to participate productively in conversations about modern network engineering from day one in a professional environment.

Comments