In Part 3 of this series, we treated our physical network topology like software application code, using Containerlab to spin up our test sandbox in seconds.
With our nodes live, how do we actually generate and push configurations to them?
If you hardcode configuration blocks directly into your Ansible tasks, your playbooks quickly become unmanageable. The moment a VLAN ID changes, or you swap a router from a Nokia SR OS node to a Cisco instance, your entire automation framework collapses under its own weight.
We solve this by decoupling our data from our execution logic. We will break down how to map design variables into structured local files, build dynamic blueprints using Jinja2 templates, and format the final payload using model-driven YANG structures rather than screen-scraping text.
Separating Data from Execution
Before diving into code, it helps to understand the architectural flow. Our pipeline separates responsibilities into three distinct layers:
The Intent (The Data): Structured variables defining what the network should look like (e.g., interface names, IP addresses, ASNs). This data can live locally in static
host_varsfiles for quick prototyping, pull dynamically from NetBox via its API for a centralized source of truth, or exist as a hybrid where NetBox handles inventory data and local files manage specific service-level variables.The Blueprint (The Logic): Modular templates (
Jinja2) that handle loops, conditionals, and formatting syntax.The Engine (The Transport): The automation framework (
Ansible) whose only job is to pair the data with the blueprint and push the resulting payload to the node.
(YAML / NetBox Data)"] B["2. THE BLUEPRINT
(Jinja2 Template)"] C["3. THE ENGINE
(Ansible Playbook)"] D["Target Routers
(Containerlab Fabric)"] %% Flow Layout A --> C B --> C C -->|Model-Driven YANG/XML Payload| D %% Styling & Theme Customization classDef boxes fill:#1e1e2e,stroke:#313244,stroke-width:2px,color:#cdd6f4; classDef engine fill:#11111b,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4; classDef targets fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4; class A,B boxes; class C engine; class D targets; %% Global link styling linkStyle default stroke:#6c7086,stroke-width:2px,linear;
1. Defining the Intent: Structuring host_vars
Instead of writing vendor commands, we define our network state inside a structured YAML file. For our lab topology, we are configuring a VPLS service between PE1 and PE2.
Create a file named group_vars/all.yml:
ansible_user: admin
ansible_password: NokiaSros1!
ansible_connection: ansible.netcommon.netconf
Create a file named host_vars/PE1.yml:
---
# Service
vpls_services:
- id: 1
vc_id: 1
description: "VPLS_Instance_1_Mesh"
sap: "1/1/c2/1:1"
mesh_sdps: [12]
Create a file named host_vars/PE2.yml:
---
# Service
vpls_services:
- id: 1
vc_id: 1
description: "VPLS_Instance_1_Mesh"
sap: "1/1/c2/1:1"
mesh_sdps: [21]
Notice that there is zero vendor syntax here. This file purely represents the metadata of the service we want to build.
📌 A Quick Note on Service Provider Architecture:
If you are new to Nokia service modeling, it helps to distinguish the two interface types we are using here:
SAP (Service Access Point): This is the physical or logical port on the PE router that connects directly down to the Customer Edge (CE) device. It defines how the customer hands off traffic to our network.
SDP (Service Distribution Point): This is the virtual transport tunnel running across the core network infrastructure that connects PE routers together, allowing the local service instances to communicate seamlessly over the provider backbone.
2. Building the Blueprint: The Jinja2 Template
Next, we build the template that will parse our variables. Because we want to move past CLI screen-scraping, we format our Jinja2 blueprint to output a structured XML data model compliant with the router’s native YANG schema.
Create a file named templates/vpls_deploy.j2:
<config xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
<configure xmlns="urn:nokia.com:sros:ns:yang:sr:conf">
<service>
{% for vpls in vpls_services %}
<vpls>
<service-id>{{ vpls.id }}</service-id>
<customer>1</customer>
<description>{{ vpls.description }}</description>
{% if vpls.sap is defined %}
<sap>
<sap-id>{{ vpls.sap }}</sap-id>
<admin-state>enable</admin-state>
</sap>
{% endif %}
{% for sdp_id in vpls.mesh_sdps %}
<!-- Core Transport Tunnel between PE Routers -->
<mesh-sdp>
<sdp-bind-id>{{ sdp_id }}:{{ vpls.vc_id }}</sdp-bind-id>
<admin-state>enable</admin-state>
</mesh-sdp>
{% endfor %}
<admin-state>enable</admin-state>
</vpls>
{% endfor %}
</service>
</configure>
</config>
Deconstructing the Template Logic:
xmlns="urn:nokia.com:sros:ns:yang:sr:conf": This tag specifies the precise YANG model namespace we are targeting. Instead of raw text strings, the router processes this as a direct, structural database manipulation.{% for vpls in ... %}: A native Jinja2 loop. If you append five more services to yourhost_varsfile later, this template dynamically loops over them and generates all five payloads without requiring you to touch the core blueprint again.
3. Driving the Engine: The Ansible Playbook
With our data isolated and our template ready, our Ansible playbook becomes beautifully lightweight. Its only responsibility is to read the template, inject the variables, and transmit the resulting XML payload via NETCONF.
Create a file named deploy_services.yml:
---
- name: "Deploy Services"
hosts: platforms_nokia_sros
gather_facts: false
tasks:
- name: "Render and Pushing YANG/XML Configuration Template"
ansible.netcommon.netconf_config:
xml: "{{ lookup('template', 'templates/vpls_deploy.j2') }}"
register: netconf_output
- name: "Print Execution Output Confirmation"
ansible.builtin.debug:
var: netconf_output
Why This Architecture Wins:
Vendor-Agnostic Extensibility: If you add a Cisco IOS-XR node to this topology tomorrow, you do not modify
deploy_services.yml. You simply write a Cisco-specific Jinja2 template and save it.Immutable Automation Logic: Your execution playbook stays locked down and version-controlled. Changes to production only happen inside data structures (
host_vars) or templates.
Executing the Pipeline
Ensure your Containerlab network from Part 3 is fully operational, then execute your deployment engine:
ansible-playbook -i netbox_inv.yml deploy_services.yml
When Ansible executes, it securely establishes a NETCONF connection over SSH, bypasses the CLI parsing engines entirely, updates the configuration candidate database on the target nodes, and commits the execution transactional loop natively.
Conclusion & Next Steps
By completely separating our data structures from our execution logic, we have transitioned our lab from a basic scripting workspace into an automation framework:
Variables (
host_vars) define the individual intent parameters.Templates (
Jinja2) map out the logic.NETCONF/YANG payloads process transactional configuration updates.
What’s Next: Verification and State Validation
Now that we can programmatically push model-driven configurations to our virtual network fabric on demand, we face our next challenge: How do we prove it actually works?
In Part 5, we will tackle the final deployment stage of our NetDevOps journey: validation. We will cover:
Utilizing state verification outputs to validate end-to-end data plane connectivity.
Parsing operational states (
showcommands) directly into structured data for automated pipeline decision-making.