← all posts

From Intent to Reality: Driving pyATS Tests from NetBox Against a CML2 Fabric

June 15, 2026 · by Brent Leekley · ~10 min read

A terminal window titled intent versus reality. On the left, intent from NetBox: spine1 router_id 1.1.1.1. On the right, reality from the CML2 fabric: spine1 running OSPF with ID 1.1.1.1. In the middle, a pyATS judge compares the two and reports a match. Caption: NetBox holds the intent, the lab is the reality, pyATS is the judge.

Part 2 of a series pairing Cisco Modeling Labs 2 with Cisco pyATS for network programmability. Part 1 built the lab and generated the testbed from CML.

New to NetBox? The official NetBox Zero to Hero course is a quick primer.

In the first post I built a four-node spine-leaf in CML2 and generated the pyATS testbed straight from the lab, so the inventory never drifts from the network. That gave us something solid to stand on: a testbed that connects, and Genie output that comes back as structured data instead of screen-scraped text.

Once the testbed connects it proves we can reach the devices and read them, but before we move on we should check that the configurations are correct. Correct compared to what? You cannot test reality until you have written down what reality is supposed to be.

We need a source of truth. We give the network a declared intent and hold it in NetBox. Then we use pyATS to check the live fabric against it. The question we are answering is simple to say and surprisingly easy to leave untested: does the network match what we said it should be?

Takeaway up front: when your intent lives as structured data, "is the network right?" is an easy check. It simply becomes a test that either passes or fails.

Three roles: intent, reality, judge

Our environment is now a clean split of responsibilities. Three things, each doing one job.

Three roles. NetBox is the intent, the declared router-IDs. The CML2 fabric is the reality, the running router-IDs. pyATS is the judge, reading from both and asserting that reality equals intent.
NetBox declares it, the fabric runs it, pyATS checks that the two agree.

Why router-IDs make a good first check

I am using the OSPF router-ID as the working example on purpose. It is the smallest check… Each device has exactly one, it is easy to declare and to read back, and a mismatch is a real operational problem. A router-ID that drifts from what you designed can split your link-state database in ways that are annoying to chase down later.

In Part 1 we set loopback router-IDs of 1.1.1.1, 2.2.2.2, 11.11.11.11, and 22.22.22.22. They are the values we declare in NetBox and test against.

Prerequisites

export NETBOX_ENDPOINT=http://localhost:8000
export NETBOX_TOKEN=...        # an API token from NetBox
export PYATS_UNAME=cisco
export PYATS_PWORD=cisco

If you want the quickest possible NetBox, clone netbox-docker, add a one-line override so the web UI is published on a port you can reach, then bring it up:

# docker-compose.override.yml
services:
  netbox:
    ports:
      - 8000:8080
docker compose pull
docker compose up -d

Create a superuser and an API token from the UI (or have the container create them for you), and you have a source of truth waiting to be filled in.

Step 1: put the intent into NetBox

NetBox does not ship with a field called "OSPF router-ID," and it should not. That is exactly what custom fields are for. We add one custom field named router_id that applies to devices, and that field becomes the home for this slice of intent.

import pynetbox

nb = pynetbox.api(ENDPOINT, token=TOKEN)

# A text custom field on devices: this is where intent lives.
nb.extras.custom_fields.create(
    object_types=["dcim.device"],
    name="router_id",
    label="Router ID",
    type="text",
    description="Declared OSPF router-ID (intent).",
)

The reality test also needs to know how to reach each device, and NetBox already models that as the device's primary IPv4. So when I build the fabric I set two things per device: the declared router_id and the management IP as its primary address. The core of the seeding loop looks like this:

# name -> (mgmt IP, declared router-ID)
FABRIC = {
    "spine1": ("198.18.250.87", "1.1.1.1"),
    "spine2": ("198.18.250.88", "2.2.2.2"),
    "leaf1":  ("198.18.250.89", "11.11.11.11"),
    "leaf2":  ("198.18.250.90", "22.22.22.22"),
}

for name, (mgmt_ip, router_id) in FABRIC.items():
    dev = nb.dcim.devices.get(name=name)
    dev.custom_fields["router_id"] = router_id      # the intent
    dev.save()

    intf = nb.dcim.interfaces.get(device_id=dev.id, name="Ethernet0/0")
    ip = nb.ipam.ip_addresses.create(
        address=f"{mgmt_ip}/24", status="active",
        assigned_object_type="dcim.interface", assigned_object_id=intf.id,
    )
    dev.primary_ip4 = ip.id                          # how to reach it
    dev.save()

You could set all of this in the NetBox UI just as easily. I keep it as a script because the whole series treats the network as something you define once and re-run, and intent is no exception. The full script, including the supporting site, role, and device-type objects NetBox requires, will be added as an appendix.

With that done, the source of truth knows what the fabric is supposed to be:

fabric intent in NetBox:
  spine1   router_id=1.1.1.1        primary_ip4=198.18.250.87/24
  spine2   router_id=2.2.2.2        primary_ip4=198.18.250.88/24
  leaf1    router_id=11.11.11.11    primary_ip4=198.18.250.89/24
  leaf2    router_id=22.22.22.22    primary_ip4=198.18.250.90/24

Two checks, one source of truth

There are actually two different questions hiding in "does the network match intent," and it is worth testing them separately.

Two checks. On the left, the fabric design holds the canonical router-IDs. The intent test checks NetBox equals the design. In the middle, NetBox is the source of truth. The reality test checks the live device equals NetBox. On the right, the live device runs OSPF.
The intent test guards the data in NetBox. The reality test guards the network against NetBox.

The intent test guards the data. A source of truth can still hold a typo, and if NetBox is wrong then everything downstream is wrong with confidence. So first we check that NetBox agrees with the design. The reality test guards the network. Once we trust NetBox, we check that the live fabric matches what NetBox declares.

Step 2: the intent test (NetBox vs the design)

This one never touches a device. It reads each device's router_id custom field from NetBox and tests it matches the design.

I write it as a normal pyATS aetest so it slots into the same runner as everything else, looping one testcase per device.

import os
import pynetbox
from pyats import aetest

ENDPOINT = os.getenv("NETBOX_ENDPOINT", "http://localhost:8000")
TOKEN = os.getenv("NETBOX_TOKEN")

# The canonical fabric design. NetBox must agree with this.
EXPECTED_ROUTER_IDS = {
    "spine1": "1.1.1.1",
    "spine2": "2.2.2.2",
    "leaf1": "11.11.11.11",
    "leaf2": "22.22.22.22",
}


class CommonSetup(aetest.CommonSetup):
    @aetest.subsection
    def get_netbox_data(self):
        nb = pynetbox.api(ENDPOINT, token=TOKEN)
        self.parent.parameters["netbox"] = {
            d.name: dict(d.custom_fields) for d in nb.dcim.devices.all()
        }

    @aetest.subsection
    def loop_mark(self):
        aetest.loop.mark(TestRouterId, device_name=list(EXPECTED_ROUTER_IDS))


class TestRouterId(aetest.Testcase):
    @aetest.test
    def test_router_id(self, device_name, netbox):
        declared = netbox[device_name]["router_id"]
        expected = EXPECTED_ROUTER_IDS[device_name]
        assert declared == expected, (
            f"{device_name}: NetBox declares {declared}, design says {expected}"
        )

Run it and NetBox checks out against the design, one testcase per device:

|-- common_setup                            PASSED
|   |-- get_netbox_data                     PASSED
|   `-- loop_mark                           PASSED
|-- TestRouterId[device_name=spine1]        PASSED
|   `-- test_router_id                      PASSED
|-- TestRouterId[device_name=spine2]        PASSED
|   `-- test_router_id                      PASSED
|-- TestRouterId[device_name=leaf1]         PASSED
|   `-- test_router_id                      PASSED
`-- TestRouterId[device_name=leaf2]         PASSED
    `-- test_router_id                      PASSED

Success Rate                              100.0%

Step 3: the reality test (the live fabric vs NetBox)

Now the interesting part. For each device we pull both halves of the contract straight from NetBox: the management IP that tells us how to reach it, and the router_id that tells us what it should be. Then we connect, run one command, and check that the running router-ID matches what NetBox declared. Notice that the expected value is no longer hard-coded in the test. NetBox is the source of truth, so the test compares reality to NetBox, not to a second copy of the answer.

The closed loop, left to right: declare the router_id in NetBox, pyATS reads the intent (router_id plus management IP), pyATS connects to the device over SSH, it compares the running state to the declared state, it reports a verdict of pass or fail, and a return arrow loops back to re-run on every change.
Declare, read, connect, compare, verdict. The same loop runs on demand or in CI.
import os
import pynetbox
from pyats import aetest
from pyats.topology import Device

ENDPOINT = os.getenv("NETBOX_ENDPOINT", "http://localhost:8000")
TOKEN = os.getenv("NETBOX_TOKEN")
UNAME = os.getenv("PYATS_UNAME", "cisco")
PWORD = os.getenv("PYATS_PWORD", "cisco")


class CommonSetup(aetest.CommonSetup):
    @aetest.subsection
    def get_netbox_intent(self):
        # Pull both halves of the contract from NetBox: how to reach the
        # device (primary_ip4) and what it should be (router_id).
        nb = pynetbox.api(ENDPOINT, token=TOKEN)
        intent = {}
        for d in nb.dcim.devices.all():
            if d.primary_ip4 and d.custom_fields.get("router_id"):
                intent[d.name] = {
                    "ip": str(d.primary_ip4).split("/")[0],
                    "router_id": d.custom_fields["router_id"],
                }
        self.parent.parameters["intent"] = intent

    @aetest.subsection
    def loop_mark(self, intent):
        aetest.loop.mark(TestOspfRouterId, device_name=list(intent))


class TestOspfRouterId(aetest.Testcase):
    @aetest.setup
    def connect(self, device_name, intent):
        self.device = Device(
            name=device_name, os="ios",
            credentials={
                "default": {"username": UNAME, "password": PWORD},
                "enable": {"password": PWORD},
            },
            connections={"cli": {
                "protocol": "ssh", "ip": intent[device_name]["ip"], "port": 22,
            }},
        )
        self.device.connect(log_stdout=False, logfile=False)

    @aetest.test
    def test_router_id(self, device_name, intent):
        declared = intent[device_name]["router_id"]
        output = self.device.execute("show ip ospf | include Process")
        assert declared in output, (
            f"{device_name}: NetBox declared {declared}, device shows: {output.strip()}"
        )

    @aetest.cleanup
    def disconnect(self):
        if self.device.connected:
            self.device.disconnect()

The live fabric agrees with NetBox on every device:

|-- common_setup                                PASSED
|   |-- get_netbox_intent                       PASSED
|   `-- loop_mark                               PASSED
|-- TestOspfRouterId[device_name=spine1]        PASSED
|   |-- connect                                 PASSED
|   |-- test_router_id                          PASSED
|   `-- disconnect                              PASSED
|-- TestOspfRouterId[device_name=spine2]        PASSED
|-- TestOspfRouterId[device_name=leaf1]         PASSED
`-- TestOspfRouterId[device_name=leaf2]         PASSED

Success Rate                                  100.0%

Green across the board is the boring, correct answer. The interesting question is whether the test actually means anything. A test that can only pass is not a test.

Step 4: break it on purpose

Let's make reality disagree with intent. I will change spine1's running router-ID on the device. On spine1:

router ospf 1
 router-id 9.9.9.9
end
clear ip ospf process     # answer yes; the new ID takes effect

NetBox still declares 1.1.1.1. The device is now running 9.9.9.9. Intent and reality have parted ways, and nothing on the box is going to tell you so on its own.

Drift, caught. Intent from NetBox is spine1 router_id 1.1.1.1. Reality on spine1 is running OSPF ID 9.9.9.9, shown in red. The pyATS judge shows not-equal. The verdict: failed on spine1, and only spine1.
NetBox says 1.1.1.1, the device runs 9.9.9.9, and the reality test fails on spine1 and nowhere else.

Re-run the reality test, and pyATS catches it cleanly. The assertion message carries both sides of the disagreement, and the failure is pinned to exactly the device that drifted:

AssertionError: spine1: NetBox declared 1.1.1.1, device shows:
                Routing Process "ospf 1" with ID 9.9.9.9

|-- TestOspfRouterId[device_name=spine1]        FAILED
|-- TestOspfRouterId[device_name=spine2]        PASSED
|-- TestOspfRouterId[device_name=leaf1]         PASSED
`-- TestOspfRouterId[device_name=leaf2]         PASSED

 Number of PASSED                                   4
 Number of FAILED                                   1
 Success Rate                                   80.0%

That is the payoff. Not a human noticing months later that something feels off, but a test that fails the moment the network stops matching what you declared, and tells you which device and what the gap is. Set the router-ID back to 1.1.1.1, clear the process again, and the test result goes back to 100 percent.

Where this goes next

Router-IDs are just the example. It generalizes to anything you are willing to declare. Once intent lives in a source of truth as structured data, each field becomes a contract pyATS can enforce against the live network:

The rest of the series keeps reusing this lab and the same idea from different angles:

Write the intent down once, and let every run prove the network still matches it.

Appendix: the full seeding script

seed_netbox.py (idempotent, safe to re-run)
#!/usr/bin/env python3
"""Seed NetBox with the spine-leaf fabric's declared intent."""
import os
import pynetbox

ENDPOINT = os.getenv("NETBOX_ENDPOINT", "http://localhost:8000")
TOKEN = os.getenv("NETBOX_TOKEN")

# name -> (mgmt IP, declared router-ID, role)
FABRIC = {
    "spine1": ("198.18.250.87", "1.1.1.1", "spine"),
    "spine2": ("198.18.250.88", "2.2.2.2", "spine"),
    "leaf1":  ("198.18.250.89", "11.11.11.11", "leaf"),
    "leaf2":  ("198.18.250.90", "22.22.22.22", "leaf"),
}

nb = pynetbox.api(ENDPOINT, token=TOKEN)


def get_or_create(endpoint, lookup, **defaults):
    obj = endpoint.get(**lookup)
    return obj or endpoint.create(**{**lookup, **defaults})


# 1. The router_id custom field on devices (this is where intent lives).
if not nb.extras.custom_fields.get(name="router_id"):
    nb.extras.custom_fields.create(
        object_types=["dcim.device"], name="router_id", label="Router ID",
        type="text", description="Declared OSPF router-ID (intent).",
    )

# 2. Supporting objects every device needs.
site = get_or_create(nb.dcim.sites, {"slug": "cml-lab"}, name="CML Lab", status="active")
mfr = get_or_create(nb.dcim.manufacturers, {"slug": "cisco"}, name="Cisco")
dtype = get_or_create(
    nb.dcim.device_types, {"slug": "iol-xe"}, model="IOL-XE", manufacturer=mfr.id
)
roles = {
    r: get_or_create(nb.dcim.device_roles, {"slug": r}, name=r.capitalize(), color="2196f3")
    for r in {v[2] for v in FABRIC.values()}
}

# 3. The four devices, each carrying its declared router-ID and a primary IP.
for name, (mgmt_ip, router_id, role) in FABRIC.items():
    dev = nb.dcim.devices.get(name=name) or nb.dcim.devices.create(
        name=name, device_type=dtype.id, role=roles[role].id,
        site=site.id, status="active",
    )
    dev.custom_fields["router_id"] = router_id
    dev.save()

    intf = nb.dcim.interfaces.get(device_id=dev.id, name="Ethernet0/0") or \
        nb.dcim.interfaces.create(device=dev.id, name="Ethernet0/0", type="1000base-t")
    ip = nb.ipam.ip_addresses.get(address=f"{mgmt_ip}/24") or \
        nb.ipam.ip_addresses.create(
            address=f"{mgmt_ip}/24", status="active",
            assigned_object_type="dcim.interface", assigned_object_id=intf.id,
        )
    if not dev.primary_ip4 or dev.primary_ip4.id != ip.id:
        dev.primary_ip4 = ip.id
        dev.save()

Fabric intent reference

DeviceRoleDeclared router-IDPrimary IPv4 (mgmt)
spine1spine1.1.1.1198.18.250.87
spine2spine2.2.2.2198.18.250.88
leaf1leaf11.11.11.11198.18.250.89
leaf2leaf22.22.22.22198.18.250.90

← all posts