← all posts

The Snapshot Machine: genie learn and diff as a Change-Validation Safety Net on CML2

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

A two-pane terminal. Left, what you check: show ip ospf neighbor returns 1.1.1.1 FULL and 2.2.2.2 FULL, looks fine. Right, what moved: pyats diff shows 1.1.1.1/32 changing from via Ethernet0/1 to via Ethernet0/2, with three routes moved. Caption: the neighbors stayed up, the routing table moved, only the diff saw it.

Part 3 of a series pairing Cisco Modeling Labs 2 with Cisco pyATS for network programmability. Part 1 built the lab, Part 2 tested it against NetBox intent.

Here is a maintenance window you have lived through. You make a small change, run a few show commands, the output looks fine, and you close the ticket. Weeks later something is slow or unreachable, and the root cause turns out to be a side effect of that change, sitting just outside the few commands you happened to check.

The problem is not that you skipped a step. The problem is that "I looked and it seemed fine" does not scale to the size of a routing table. You cannot eyeball every route, every neighbor, and every counter before and after a change and reliably spot the one thing that moved.

So let the tooling do it. Genie can snapshot the operational state of a device as structured data, and it can diff two snapshots and tell you exactly what changed. Take one snapshot before the change and one after, diff them, and the side effects stop hiding. CML2 makes this safe to practice, because you can break things on purpose with zero blast radius on anything real.

Takeaway up front: structured before-and-after diffs catch the side effects that "the show commands looked fine" misses.

The loop: learn, change, learn, diff

The whole workflow is four steps, and it fits inside any maintenance window.

The maintenance-window loop, left to right: pyats learn a baseline (pre), make the change in the window, pyats learn again (post), pyats diff pre against post, then review what moved. CML2 lets you break it on purpose with zero risk.
Snapshot before, snapshot after, diff. The same loop runs on demand or in CI.

genie learn (exposed on the CLI as pyats learn) connects to a device, runs the show commands behind a feature, and parses them into a structured model. genie diff (as pyats diff) compares two of those models key by key. You are not diffing screen text, you are diffing parsed state, so a route that changed next-hop shows up as a clean before-and-after on that one field.

Snapshot the baseline

I am working against the same four-node spine-leaf from Part 1, snapshotting leaf1. Two features carry this story: ospf (so we see adjacencies and per-interface cost) and routing (so we see the RIB itself). Learn the baseline into a pre_maint directory:

pyats learn ospf routing --devices leaf1 \
  --testbed-file testbeds/testbed.yaml \
  --output output/pre_maint
+==============================================================================+
| Genie Learn Summary for device leaf1                                          |
+==============================================================================+
|  Connected to leaf1                                                           |
|  Learnt feature 'ospf'                                                        |
|  Learnt feature 'routing'                                                     |
+==============================================================================+

Before we touch anything, it is worth knowing what the baseline says about two destinations, because these are the ones that will move:

22.22.22.22/32   metric 21   via Ethernet0/2 and Ethernet0/1   (two paths, ECMP)
1.1.1.1/32       metric 11   via Ethernet0/1                    (direct to spine1)

leaf1 reaches leaf2's loopback (22.22.22.22) over both spines equally, and it reaches spine1's loopback (1.1.1.1) straight across the direct link. Hold that picture.

The change: a subtle one, on purpose

I am not going to shut an interface. That is too easy to catch, the link goes down and everyone notices. The interesting failure is the quiet one, so I will fat-finger an OSPF cost: bump leaf1's link to spine1 from its default cost of 10 up to 100, the kind of thing that happens when a template lands the wrong value or someone tunes one link and forgets the rest of the fabric reacts to it.

leaf1(config)# interface Ethernet0/1
leaf1(config-if)# ip ospf cost 100

No interface bounced. No protocol restarted. Now learn the after-state into a post_maint directory with the exact same command, and diff the two:

pyats learn ospf routing --devices leaf1 \
  --testbed-file testbeds/testbed.yaml \
  --output output/post_maint

pyats diff output/pre_maint output/post_maint --output output/results

First, what the show commands tell you

This is the trap. The most natural thing to check after an OSPF change is your adjacencies, so you run show ip ospf neighbor, and it is spotless:

Neighbor ID     Pri   State           Dead Time   Address     Interface
2.2.2.2           0   FULL/  -        00:00:38    10.0.3.0    Ethernet0/2
1.1.1.1           0   FULL/  -        00:00:30    10.0.1.0    Ethernet0/1

Both neighbors are FULL. The adjacency to spine1 over Ethernet0/1 is up, exactly as it was before. A cost change does not drop an adjacency, so every neighbor-facing command you reach for says the same thing: nothing is wrong. You would close the ticket here. The diff is about to disagree.

Reading a genie diff

A genie diff reads like a unified diff, but over parsed state instead of text. Lines that start with a minus are the before snapshot (pre_maint), lines that start with a plus are the after (post_maint), and everything else is unchanged context that locates the change in the structure.

Reading a genie diff. A diff panel shows minus pre_maint as the before file and plus post_maint as the after file, then for route 1.1.1.1/32 a minus line outgoing_interface Ethernet0/1 in red is the before state, and a plus line outgoing_interface Ethernet0/2 in green is the after state. Minus is what it was, plus is what it became.
Minus is what it was, plus is what it became, in the exact spot in the model where it moved.

The diff run wrote one file per feature that changed:

+==============================================================================+
| Genie Diff Summary between directories                                        |
| output/pre_maint/ and output/post_maint/                                     |
+==============================================================================+
|  File: routing_ios_leaf1_ops.txt                                             |
|   - Diff can be found at output/results/diff_routing_ios_leaf1_ops.txt        |
|  File: ospf_ios_leaf1_ops.txt                                                 |
|   - Diff can be found at output/results/diff_ospf_ios_leaf1_ops.txt           |
+==============================================================================+

What you actually did

The OSPF diff is short and unsurprising. It is the one thing you meant to change:

--- output/pre_maint/ospf_ios_leaf1_ops.txt
+++ output/post_maint/ospf_ios_leaf1_ops.txt
          interfaces:
           Ethernet0/1:
-           cost: 10
+           cost: 100

What actually happened

The routing diff is the whole point of the post. One cost change, and the RIB moved underneath you:

--- output/pre_maint/routing_ios_leaf1_ops.txt
+++ output/post_maint/routing_ios_leaf1_ops.txt
       1.1.1.1/32:
-       metric: 11
+       metric: 31
        next_hop:
         next_hop_list:
          1:
-          next_hop: 10.0.1.0
+          next_hop: 10.0.3.0
-          outgoing_interface: Ethernet0/1
+          outgoing_interface: Ethernet0/2
       10.0.2.0/31:
-       metric: 20
+       metric: 30
         (next-hop moves from Ethernet0/1 to Ethernet0/2)
       22.22.22.22/32:
        next_hop:
         next_hop_list:
-         2:
-          next_hop: 10.0.1.0
-          outgoing_interface: Ethernet0/1

Read that top entry again. 1.1.1.1 is spine1's own loopback. Before the change, leaf1 reached it straight across the direct link on Ethernet0/1. After the change, leaf1 reaches spine1 by going the long way around, out Ethernet0/2 to spine2, across to leaf2, and back up to spine1, at more than double the metric. The direct adjacency to spine1 is still FULL the entire time. You are simply no longer using the direct link to get to the device on the other end of it.

Spine-leaf topology with spine1 and spine2 on top, leaf1 and leaf2 below. Before the cost change, leaf1 reaches spine1's loopback 1.1.1.1 directly over Ethernet0/1, drawn as a solid green arrow straight up. After raising the Ethernet0/1 cost, leaf1 reaches the same 1.1.1.1 the long way, leaf1 to spine2 to leaf2 to spine1, drawn as an amber dashed path over Ethernet0/2. The leaf1 to spine1 adjacency stays FULL the whole time.
spine1's loopback, reached the long way around, while the direct adjacency stays up.

And it is not just that one route. The fabric link 10.0.2.0/31 rerouted the same way, and 22.22.22.22 quietly lost its second equal-cost path, dropping from two next-hops to one. One line of config, three destinations moved, zero adjacencies dropped.

One change fans out. The single change, ip ospf cost 100 on leaf1 Ethernet0/1, leads to three moved routes: 1.1.1.1/32 rerouted via spine2, 10.0.2.0/31 rerouted via spine2, and 22.22.22.22/32 lost an ECMP path. A fourth box notes OSPF adjacencies had zero changes and stayed FULL.
The blast radius of one cost change, none of which a neighbor check would have shown you.

None of this is exotic. It is exactly how OSPF is supposed to behave. That is the point: the routing table did the right thing, the change still had consequences you did not intend, and the only thing that surfaced them was a structured before-and-after diff. A neighbor check would have sent you home happy.

Make it a habit

The reason to wire this into a real maintenance routine is that it costs almost nothing and it is honest in a way that eyeballing is not.

Where this goes next

This is the same lab from Part 1, doing a new job again. The series keeps building on it:

Snapshot before, snapshot after, and let the diff tell you what you actually did.

Appendix: the maintenance-window script

learn, change, learn, diff (run inside the window)
#!/usr/bin/env bash
# Snapshot leaf1 before and after a change, then diff the structured state.
# Credentials come from the environment (see Part 1): PYATS_UNAME / PYATS_PWORD.
set -euo pipefail

TB=testbeds/testbed.yaml

# 1. baseline, before the change
pyats learn ospf routing --devices leaf1 \
  --testbed-file "$TB" --output output/pre_maint

echo "Make your change now, then press enter..."
read -r

# 2. after the change
pyats learn ospf routing --devices leaf1 \
  --testbed-file "$TB" --output output/post_maint

# 3. compare; per-feature diffs land in output/results/
pyats diff output/pre_maint output/post_maint --output output/results

echo "Review output/results/ . An empty result means nothing moved."

What moved, at a glance

DestinationBeforeAfterWhat it is
1.1.1.1/32via Ethernet0/1, metric 11via Ethernet0/2, metric 31spine1 loopback, now the long way
10.0.2.0/31via Ethernet0/1, metric 20via Ethernet0/2, metric 30spine1 to leaf2 fabric link
22.22.22.22/32via Ethernet0/1 and Ethernet0/2via Ethernet0/2 onlyleaf2 loopback, lost an ECMP path
OSPF adjacenciesboth FULLboth FULLunchanged, the trap

← all posts