Network IaC, Part 4: One Baseline for Access Ports
Taking the pipeline from flat NTP lines to an interface block on Arista, Nexus and Cumulus: learn what each switch actually stores, flatten the block so the diff still works, and deploy it through the same review and gates.
on this page
Network IaC from a brownfield lab. Part 4 of a series. Part 1 built the Ansible workflow: collect, model, diff offline, apply, re-collect. Part 2 put it under CI for a team. Part 3 deployed reviewed changes from a separate runner and checked for drift. This part puts a new kind of config through the same pipeline: an access-port baseline.
Parts 1 to 3 managed two kinds of line, an NTP server and a syslog host. Both are flat. A line is in the running config or it isn’t, and the diff is a set difference.
An access port isn’t flat on two of the three platforms:
interface Ethernet3
description host-a
switchport access vlan 10
spanning-tree portfast
spanning-tree bpduguard enablespanning-tree portfast means nothing without the interface Ethernet3 above it. Compare it as a bare line and the diff can’t tell Ethernet3 from Ethernet4. So this part has one new problem: comparing and remediating lines that only mean something under a parent, on three vendors, without changing anything else in the pipeline.
The scope is one access port per switch with four settings: description, access VLAN, STP edge mode and BPDU guard. I started with a bigger baseline, and it grew a per-vendor exception matrix faster than it grew anything useful, so I cut it.
Lab setup#
Same lab and model as parts 1 to 3: Arista vEOS 4.32, Cisco Nexus 9300v 10.6(3) and Cumulus VX 5.9.1 in EVE-NG, Gitea 1.27.1 with act_runner, and the CI and deploy runners from part 3. Port 3 on each switch goes to a VPCS host. This part writes to devices: every deployment below happened, log lines are from real runs and trimmed, and times are UTC, all from 15 October. The “team” is still partly simulated: darren owns everything and reviewer01 is a second account I control.
Learn what the switch stores, before writing a template#
Part 1 learned this the hard way: the Nexus template rendered logging server … 5 use-vrf management, and the device stored the line without the 5, so the diff saw a line that was missing and one that was extra. So before any template, I configured port 3 by hand on each switch and read the config back.
| Stored after configuring port 3 | What it means for the template | |
|---|---|---|
| Arista01 | vlan 10 with name USERS; under the interface description, switchport access vlan 10, spanning-tree portfast, spanning-tree bpduguard enable | stored exactly as typed |
| NXOS | vlan 1,10 (a list line that isn’t mine), then vlan 10 with name USERS; under the interface description, switchport access vlan 10, spanning-tree port type edge, spanning-tree bpduguard enable | switchport, switchport mode access and no shutdown were not stored. They are defaults. Render them and the diff shows them missing on every run |
| Cumulus01 | six flat lines: nv set bridge domain br_default vlan 10, and for swp3 description, type swp, bridge domain br_default access 10, stp admin-edge on, stp bpdu-guard on | type swp appears on its own. The template rendered it, or it was an extra line forever. NVUE has no VLAN name. (Part 5 shows why that line had to go again: once neighbouring ports exist, NVUE prints it as a range.) |
The model and one template#
The VLANs are shared by all devices, and the ports are per device:
# inventory/group_vars/all/intent.yml (added)
vlans:
10: USERS
access_ports: {}# inventory/host_vars/Arista01/ports.yml
access_ports:
Ethernet3:
vlan: 10
description: host-aSTP edge and BPDU guard aren’t in the model. They are the baseline of an access port, so the templates always render them. Each vendor has one template in the form its switch stores. Arista’s:
{# templates/eos/access_ports.j2 #}
{% for id, name in vlans.items() | sort %}
vlan {{ id }}
name {{ name }}
{% endfor %}
{% for port, p in access_ports.items() | sort %}
interface {{ port }}
description {{ p.description }}
switchport access vlan {{ p.vlan }}
spanning-tree portfast
spanning-tree bpduguard enable
{% endfor %}The Nexus template is the same shape with spanning-tree port type edge and no switchport line, which is the point of the table above. Cumulus renders the six flat nv set lines (six at the time of this part; see the note under the table). Each device still renders to one file: a config.j2 per platform includes the NTP and syslog template and this one.
Flatten, compare, un-flatten#
The idea that keeps part 1’s diff alive: reduce a hierarchical config to lines that carry their parent, so every line is self-contained again.
interface Ethernet3 :: spanning-tree portfastThe set difference then works unchanged. The model owns the blocks it lists, so on Arista and Nexus every child line of vlan 10 and interface Ethernet3 is owned, and an extra shutdown under that port counts as drift. A line under an unlisted interface never does. That is a deliberate choice for these dedicated lab ports, which the model is meant to own completely. On a brownfield port that carries an ACL, a QoS policy or anything else the model doesn’t mention, the same rule would show those lines as extra and offer to remove them, so a real rollout needs narrower patterns. On Cumulus there are no blocks, so ownership is five named attributes of the listed ports. The flattening is one small function:
def owned_lines(text, owned_regex, contexts):
"""Reduce a config to the lines the model owns, each one self-contained.
An unindented line that matches owned_regex is kept as it is.
An unindented line listed in contexts opens a block: every indented line under
it is kept as 'context :: child'. A blank line or '!' closes the block.
"""
owned = re.compile(owned_regex)
opened = set(contexts)
result, context = [], None
for raw in text.splitlines():
line = raw.strip()
if not line or line == "!":
context = None
elif not raw[0].isspace():
context = line if line in opened else None
if owned.match(line):
result.append(line)
elif context:
result.append(context + SEP + line)
return result(This is the version of owned_lines from filter_plugins/access.py as it was for this part, and it sits next to a second function, build_remediation, which does the reverse. Part 5 has to generalize it for nested blocks.)
The other direction rebuilds commands from the differences, and two details decide whether the result is safe to send:
- Order. A VLAN has to exist before a port uses it, so VLAN blocks come first, and each block is closed with
exit. - Single-valued settings. If the description changes from
wrongtohost-a, the diff has one missing line and one extra line. Sending the new description and thenno description wrongwould delete the value just set. Settings that hold one value are matched by a pattern, and their removal is dropped when an addition sets the same thing.
Proving it offline, against the real snapshots#
The backups history holds two real snapshots of the same devices: one collected while port 3 was configured by hand, and the one before it, clean. Rendering the model and diffing it against both needs no device:
CONFIGURED: Arista01 0 missing, 0 extra NXOS 0 missing, 0 extra Cumulus01 0 missing, 0 extra
CLEAN: Arista01 5 missing, 0 extra NXOS 5 missing, 0 extra Cumulus01 6 missing, 0 extraThe model matches what the devices stored, line for line, including the Nexus lines that are missing on purpose. Against the clean state, the remediation for Arista01 is:
vlan 10
name USERS
exit
interface Ethernet3
description host-a
spanning-tree bpduguard enable
spanning-tree portfast
switchport access vlan 10
exitThen I changed a copy of the Arista snapshot the way a person would: a wrong description, BPDU guard removed, and an extra shutdown. The result was two missing and two extra lines, and a remediation that sets the description, enables BPDU guard and sends no shutdown. There is no no description wrong.
Tests and CI#
The tests run in the checks job from part 2, offline:
- Unit tests for the filters (13). They pin the details above: children carry their parent, a child of an unlisted interface is ignored, the Nexus
vlan 1,10list line isn’t a block, the VLAN comes before the port, and a new value replaces the old one without ano. - An end-to-end test that renders a small fixed inventory and diffs it against fixture snapshots, expecting 0 and 0 for configured and 5, 5 and 6 missing for clean.
- Input rules in the inventory check: no undefined VLAN, no reserved fabric or loop port, and a description made of simple words only, because a
;or a newline could smuggle in a second command. I tried an undefined VLAN, a reserved port and a description with a;on a throwaway copy, and each failed with its own message.
One more guard went into the diff: an empty or missing backup file fails the run. Otherwise a failed collection reads as a device with nothing owned on it.
Deploying it#
The deploy workflow from part 3 needed no change except one edit to apply.yml: match: none on the Arista and Nexus pushes. Those modules normally compare the lines with the running config and skip the ones already there, which would skip a vlan 10 or interface line that is already configured and then send its children in the wrong context. match: none turns that comparison off. It does not promise that the stream reaches the device unchanged: the same modules also drop a line that is identical to an earlier one when they are given a flat list of lines with no parents, which is how my pipeline passes them. (I didn’t test how they treat the same text under different parents.) In this part the only repeated line was exit, and dropping it was harmless in these sequences. That isn’t true in general, and in part 5 the repeated lines were real commands.
The first deployment ran with limit: all and allow_removals: true. That wasn’t the plan: I meant to deploy one device at a time with removals off, and the extra permission only did no harm because the plan had no removals. The plan:
== Arista01
vlan 10
name USERS
exit
interface Ethernet3
…
== Cumulus01: nothing to send
== NXOS
vlan 10
…
plan: missing=10 extra=0Cumulus01 had nothing to send. The hand-made swp3 config from the stored-form capture was still there, the model matched, and the deploy was right to send nothing. It also meant the Cumulus path hadn’t run. The gate re-collected Arista01 and NXOS, and the diff came back owned=9 missing=0 extra=0 for each, so the exit lines and match: none work on a real Arista and a real Nexus. I then removed the hand config from Cumulus01 and deployed it alone. That run’s recap showed failed=0 for the whole play, convergence gate included, so the nv set lines, nv config apply -y and nv config save work too. I only kept the recap of that run, not its full log.
After the merged model was in place, the drift job:
GREEN: owned lines match model d2a37b1 on all devices
recorded: CONVERGED is now d2a37b1What this does not show#
- I haven’t repeated the red-drift test for port config. Part 3 showed a hand edit turning drift red for NTP lines. I haven’t made a hand edit to a port and watched it go red, or watched a deploy put it back. The mechanism is the same, but I haven’t run it.
- Ownership follows the model. Remove a port or a VLAN from the model and the pipeline stops watching it. It isn’t cleaned up. On Cumulus, ownership is five attributes, so other settings on a modelled port aren’t seen.
- The model owns whole interfaces. On Arista and Nexus that suits dedicated lab ports. It would pull unrelated settings on a shared brownfield port into the diff.
- The removal guard counts a replaced value as a removal. The remediation never sends a
nofor it, and the unit tests cover that. But the guard counts the lines that differ, not the lines it sends, so changing a description would needallow_removals. I expect that from how the pieces fit, and I haven’t run it. - Cumulus removals are untested. Every Cumulus deployment so far was an addition, so the
nv unsetform never ran on a real device. - Storm-control isn’t modelled. Arista’s EVE image rejected it with “storm-control not supported on this hardware platform”. Cumulus VX accepted it and read it back as
applied 1000for broadcast, multicast and unknown-unicast (I had expected VX not to support it), and Nexus accepted it in my first hand test (I didn’t keep that output). Accepting a setting isn’t enforcing it, and I generated no traffic. - One port, one VLAN, one owner. The model has a single access port per switch, and the approvals come from a second account I control.
Conclusion#
An access port is now modelled, reviewed, tested, deployed and compared with drift on three vendors through the same pipeline as the NTP lines. The one change in the machinery is a small filter that makes an interface block comparable line by line. The model matches the real devices in an offline comparison, and the drift job is green on the merged model.
What I’d keep from this part is the order of work: learn the stored form first, on one port by hand, and write the template from that.
Next in this series: a green config line says the setting is on the device, not that the device does what it says. The next post puts a BGP unnumbered fabric through the same pipeline across the same three switches, where the proof is traffic and not config lines, and a read-only check that pings is part of the deploy gate.
References
Versions: ansible 14.4.0 with ansible-core 2.21.4, arista.eos 12.2.0, cisco.nxos 11.2.0.