Octet

Render switch interface configs from YAML with a Jinja2 template

PythonCore

Tasks

  1. Write data/asw1.yml with the hostname and a list of interfaces.
  2. Write templates/interfaces.j2 with branches for access and trunk ports and an optional voice VLAN.
  3. Write render.py that renders with trim_blocks, lstrip_blocks and StrictUndefined and saves the result.
  4. Render ASW1 and check the output line by line.
  5. Remove a required value and confirm the render fails.

Solution

  1. 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]
  2. 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 defined makes the voice VLAN optional. default(true) means a port is enabled unless the data says otherwise; it only replaces a missing value, so an explicit false stays false.

  3. 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_blocks removes the newline after a block tag and lstrip_blocks removes spaces before it, so the {% %} lines leave nothing behind. StrictUndefined raises an error for any missing variable.

  4. 4Render
    Linux
    python3 render.py data/asw1.yml

Verify

Output
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.

Output
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_blocks and lstrip_blocks and 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 join needs a list. A single integer in allowed_vlans fails 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.

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."