Render switch interface configs from YAML with a Jinja2 template
PythonCore
Tasks
- Write
data/asw1.ymlwith the hostname and a list of interfaces. - Write
templates/interfaces.j2with branches for access and trunk ports and an optional voice VLAN. - Write
render.pythat renders withtrim_blocks,lstrip_blocksandStrictUndefinedand saves the result. - Render ASW1 and check the output line by line.
- Remove a required value and confirm the render fails.
Solution
1Data
YAML # data/asw1.yml hostname: ASW1 interfaces: - name: GigabitEthernet1/0/1 description: PC-101 mode: access vlan: 10 voice_vlan: 20 - name: GigabitEthernet1/0/2 description: PRINTER-2F mode: access vlan: 10 - name: GigabitEthernet1/0/24 description: UNUSED mode: access vlan: 999 enabled: false - name: GigabitEthernet1/0/48 description: UPLINK-DSW1 mode: trunk allowed_vlans: [10, 20, 30]2Template in templates/interfaces.j2
Text {% for intf in interfaces %} interface {{ intf.name }} description {{ intf.description }} {% if intf.mode == "access" %} switchport mode access switchport access vlan {{ intf.vlan }} {% if intf.voice_vlan is defined %} switchport voice vlan {{ intf.voice_vlan }} {% endif %} spanning-tree portfast {% elif intf.mode == "trunk" %} switchport mode trunk switchport trunk allowed vlan {{ intf.allowed_vlans | join(",") }} {% endif %} {% if intf.enabled | default(true) %} no shutdown {% else %} shutdown {% endif %} ! {% endfor %}is definedmakes the voice VLAN optional.default(true)means a port is enabled unless the data says otherwise; it only replaces a missing value, so an explicitfalsestaysfalse.3Render script
Python # render.py import sys from pathlib import Path import yaml from jinja2 import Environment, FileSystemLoader, StrictUndefined env = Environment( loader=FileSystemLoader("templates"), trim_blocks=True, lstrip_blocks=True, undefined=StrictUndefined, ) data = yaml.safe_load(Path(sys.argv[1]).read_text()) config = env.get_template("interfaces.j2").render(**data) out = Path("build") / f"{data['hostname']}.cfg" out.parent.mkdir(exist_ok=True) out.write_text(config) print(config, end="")trim_blocksremoves the newline after a block tag andlstrip_blocksremoves spaces before it, so the{% %}lines leave nothing behind.StrictUndefinedraises an error for any missing variable.4Render
Linux python3 render.py data/asw1.yml
Verify
python3 render.py data/asw1.yml
interface GigabitEthernet1/0/1
description PC-101
switchport mode access
switchport access vlan 10
switchport voice vlan 20
spanning-tree portfast
no shutdown
!
interface GigabitEthernet1/0/2
description PRINTER-2F
switchport mode access
switchport access vlan 10
spanning-tree portfast
no shutdown
!
interface GigabitEthernet1/0/24
description UNUSED
switchport mode access
switchport access vlan 999
spanning-tree portfast
shutdown
!
interface GigabitEthernet1/0/48
description UPLINK-DSW1
switchport mode trunk
switchport trunk allowed vlan 10,20,30
no shutdown
!No blank lines, one-space indentation under each interface, the voice VLAN only on Gi1/0/1, the unused
port shut, and the trunk list joined with commas. The same text is saved in build/ASW1.cfg.
python3 render.py data/asw1.yml
Traceback (most recent call last):
...
jinja2.exceptions.UndefinedError: 'dict object' has no attribute 'vlan'Run after deleting vlan: 10 from GigabitEthernet1/0/2 in the data file. The render stops instead of writing switchport access vlan with no number. Without StrictUndefined,
Jinja2 would print an empty string and you would push broken config.
Mistakes people make
- Leaving out
trim_blocksandlstrip_blocksand getting a blank line for every tag. - Using the default undefined behavior, which silently renders missing values as empty strings.
- Putting device-specific values in the template instead of the data file.
- Building logic-heavy templates. If a template needs many nested conditions, the data model needs work.
- Forgetting that
joinneeds a list. A single integer inallowed_vlansfails or renders wrongly.
On other platforms
The same template from Ansible. The template module turns trim_blocks on by default but not
lstrip_blocks, so either set lstrip_blocks: true or start every tag in column one. Undefined
variables already fail in Ansible.
- name: Render interface config
ansible.builtin.template:
src: templates/interfaces.j2
dest: "build/{{ inventory_hostname }}.cfg"
lstrip_blocks: true
delegate_to: localhost
- name: Push it
cisco.ios.ios_config:
src: "build/{{ inventory_hostname }}.cfg"Say it in the interview
"Data in YAML, structure in the template. I render with trim_blocks and lstrip_blocks so the output is
clean, and with StrictUndefined so a missing VLAN breaks the build instead of reaching a switch. Optional
settings use is defined or default. In a pipeline the rendered files are diffed and reviewed before anything is pushed."